17 Commits

Author SHA1 Message Date
-Puter
6364c33499 updates 2026-08-01 22:45:22 +05:30
-Puter
db9afd7a24 fix(lint): resolve all lint errors in github search and settings UI
- Merge duplicate react imports in repository-selector
- Use <dialog open> instead of role=dialog in settings popover
- Export type re-export from source module in use-github-repo-search
- Sort action args alphabetically in searchGithubRepositories
- Disable react-compiler rule for synchronous setState in debounced search
- Simplify getActiveGithubConnection query
2026-08-01 20:38:57 +05:30
-Puter
ce16813fbc feat(github): live repo search with /user/repos pagination and connection gear UI
- searchGithubRepositories action fetches up to 300 repos via paginated
  /user/repos, filters by name, upserts results into gitRepositories so
  createProjectFromRepository works unchanged; token never leaves backend
- connectGithub now persists grantedScopesJson from getAccessToken().scopes
- listProviderAccounts and list queries return grantedScopesJson
- Gear/settings popover on connected GitHub chip shows scopes and triggers
  linkSocial with expanded scope set [repo, read:org, read:user, user:email]
- RepositorySelector merges debounced live search results with synced snapshot
- Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
2026-08-01 20:33:55 +05:30
-Puter
95c23db4fc feat(web): debounced github repo search and connection settings
Search hook calls gitConnections:searchGithubRepositories with the
stored encrypted credential (token never reaches the browser).
Connection settings popover shows granted scopes and drives
reauthorization with the expanded scope set.
2026-08-01 20:18:27 +05:30
-Puter
08bc5ae259 feat(web): vercel deployment and vds compose/systemd setup
Add Vercel config with React Router preset, vercel-build script and
/api/auth rewrite to the Convex site. Add VDS staging compose for the
Rivet engine and runner plus systemd units, deployment plan, and
Docker/Vercel ignore rules. Lockfile covers both the web and agents
dependency additions.
2026-08-01 20:18:09 +05:30
-Puter
4dc878b8cb fix(agents): bundle workspace deps into flue runtime
Replace @code/env and @code/primitives imports with local modules so
the Node deploy artifact does not resolve workspace packages to
TypeScript source at runtime. Switch Docker build to pnpm frozen
install, add runner Dockerfile and a liveness health endpoint.
2026-08-01 20:17:49 +05:30
-Puter
3e3f06403f fix(backend): github connection identity and live repo search
Resolve identity in actions and pass organizationId into sub-queries,
since runQuery from an action does not propagate auth. Add live GitHub
repository search backed by the stored encrypted credential, persisting
results into gitRepositories. Webhook now targets CONVEX_SITE_URL
directly instead of the web origin.
2026-08-01 20:17:45 +05:30
-Puter
8bd3953440 flue flow fixes 2026-07-31 23:43:49 +05:30
-Puter
bb4bd9e0d3 .ts import fixees 2026-07-31 21:00:47 +05:30
-Puter
f208d4e4b8 projects layout 2026-07-31 20:52:43 +05:30
-Puter
fc1d0b6916 github 0auth fixes 2026-07-31 19:29:00 +05:30
-Puter
fc8cfabacb refactor projects page 2026-07-31 18:53:04 +05:30
-Puter
ce1f33aa69 feat: consolidate agent execution stack 2026-07-31 18:26:41 +05:30
-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
171 changed files with 11844 additions and 5499 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Repo-wide ignore for the agents/runner Docker builds.
# Keeps images small and prevents secrets from entering image layers.
node_modules
**/node_modules
**/dist
**/.flue-vite
# Secrets — never bake into an image.
.env
.env.*
!.env.example
**/.env
**/.env.*
# Local Convex / runtime state.
.convex
**/.convex
data
**/data
# VCS, caches, and editor cruft.
.git
.gitignore
**/.DS_Store
**/.vscode
**/.idea
# macOS AppleDouble sidecars are metadata, never TypeScript source.
**/._*
# Workspaces and runner artifacts created at runtime on the host.
workspaces
**/workspaces
*.log

View File

@@ -20,10 +20,12 @@ DAEMON_COMMAND_LEASE_MS=60000
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
FLUE_URL=http://localhost:3583
FLUE_URL=http://127.0.0.1:3585
# Agent model provider
AGENT_MODEL_PROVIDER=xiaomi

21
.gitignore vendored
View File

@@ -55,3 +55,24 @@ coverage
tmp
temp
.env.*
.env*
infra/ansible/.gitignore
infra/ansible/ansible.cfg
infra/ansible/README.md
infra/ansible/requirements.yml
infra/ansible/group_vars/staging_vds.example/main.yml
infra/ansible/group_vars/staging_vds.example/vault.yml
infra/ansible/inventory/hosts.example.yml
infra/ansible/playbooks/converge.yml
infra/ansible/roles/backup/handlers/main.yml
infra/ansible/roles/backup/tasks/main.yml
infra/ansible/roles/backup/templates/zopu-backup.service.j2
infra/ansible/roles/backup/templates/zopu-backup.sh.j2
infra/ansible/roles/backup/templates/zopu-backup.timer.j2
infra/ansible/roles/common/tasks/main.yml
infra/ansible/roles/directories/tasks/main.yml
infra/ansible/roles/docker/handlers/main.yml
infra/ansible/roles/docker/tasks/main.yml
infra/ansible/roles/docker/vars/main.yml
infra/ansible/roles/firewall/handlers/main.yml
infra/ansible/roles/firewall/tasks/main.yml

17
.vercelignore Normal file
View File

@@ -0,0 +1,17 @@
# Archived implementations and agent reference material are not part of the web build.
repos/
.agents/
.codex/
# Private deployment operations are deployed to the VDS, not Vercel.
deploy/
infra/
docs/
# Local development state and tooling.
.vscode/
coverage/
.env
.env.*
packages/backend/.convex/
.vercel/output/

View File

@@ -35,6 +35,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vercel/react-router": "1.3.1",
"react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",

View File

@@ -1,7 +1,8 @@
import type { Config } from "@react-router/dev/config";
import { vercelPreset } from "@vercel/react-router/vite";
export default {
appDirectory: "src",
presets: [vercelPreset()],
ssr: true,
} satisfies Config;

View File

@@ -0,0 +1,74 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { ProviderChips } from "./provider-chips";
import type { GitProviderAccountOption } from "./provider-chips";
import { RepositorySelector } from "./repository-selector";
export const AddProjectPanel = ({
accounts,
existingSourceUrls,
onClose,
onProjectCreated,
}: {
readonly accounts: readonly GitProviderAccountOption[] | undefined;
readonly existingSourceUrls: readonly string[];
readonly onClose: () => void;
readonly onProjectCreated: (projectId: string) => void;
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const githubConnectionId = accounts?.find(
(account) => account.provider === "github" && account.status === "active"
)?.id as Id<"gitConnections"> | undefined;
useEffect(() => {
const dialog = dialogRef.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return createPortal(
<dialog
aria-labelledby="add-project-heading"
className="fixed inset-0 z-50 m-0 flex h-full w-full max-w-none items-center justify-center bg-[#20201d]/45 p-2 backdrop-blur-[2px] sm:p-6"
onCancel={(event) => {
event.preventDefault();
onClose();
}}
ref={dialogRef}
>
<section className="relative flex h-[calc(100svh-1rem)] w-full max-w-2xl flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(46rem,calc(100svh-3rem))]">
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
<h2
className="text-base font-semibold tracking-tight text-[#20201d]"
id="add-project-heading"
>
Add project
</h2>
<button
aria-label="Close add project"
className="grid size-8 shrink-0 place-items-center rounded text-[#747168] transition-colors hover:bg-[#f2f0e7] hover:text-[#20201d]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden p-5">
<ProviderChips accounts={accounts} />
<RepositorySelector
existingSourceUrls={existingSourceUrls}
githubConnectionId={githubConnectionId}
onProjectCreated={onProjectCreated}
/>
</div>
</section>
</dialog>,
document.body
);
};

View File

@@ -0,0 +1,70 @@
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 { useMutation, useQuery } from "convex/react";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export 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 textareaId = `context-textarea-${projectId}`;
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={textareaId}
>
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={textareaId}
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>
);
};

View File

@@ -0,0 +1,36 @@
import { authClient } from "@code/auth/web";
import { LoaderCircle, GitBranch } from "lucide-react";
import { useState } from "react";
export 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>
);
};

View File

@@ -0,0 +1,189 @@
import { authClient } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/** Expanded scope set for repository + organization visibility. */
const GITHUB_EXPANDED_SCOPES = [
"repo",
"read:org",
"read:user",
"user:email",
] as const;
interface GithubConnectionSettingsProps {
readonly connectionId: string;
readonly externalUsername: string | undefined;
readonly grantedScopesJson: string | undefined;
}
export const GithubConnectionSettings = ({
connectionId,
externalUsername,
grantedScopesJson,
}: GithubConnectionSettingsProps) => {
const [open, setOpen] = useState(false);
const [reauthorizing, setReauthorizing] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
// Close on outside click.
useEffect(() => {
if (!open) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
// Parse granted scopes from stored JSON.
const grantedScopes: readonly string[] = (() => {
if (!grantedScopesJson) {
return [];
}
try {
const parsed = JSON.parse(grantedScopesJson) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((s): s is string => typeof s === "string");
}
return [];
} catch {
return [];
}
})();
const missingScopes = GITHUB_EXPANDED_SCOPES.filter(
(scope) => !grantedScopes.includes(scope)
);
const handleReauthorize = async () => {
setReauthorizing(true);
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
// Pass the full desired scope set so GitHub grants everything.
scopes: [...GITHUB_EXPANDED_SCOPES],
});
} catch {
setReauthorizing(false);
}
};
// Silence unused-vars: connectionId identifies which row is being managed.
void connectionId;
return (
<div className="relative" ref={popoverRef}>
<Button
aria-label="GitHub connection settings"
aria-haspopup="dialog"
aria-expanded={open}
className="h-8 w-8 gap-1 rounded border-[#d7d3c7] bg-[#fffefa] px-0 text-[#747168] hover:bg-[#f5f3eb]"
disabled={reauthorizing}
onClick={() => setOpen((value) => !value)}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="size-3.5" />
</Button>
{open ? (
<dialog
aria-modal="false"
className="absolute top-9 right-0 z-50 w-72 rounded-lg border border-[#d7d3c7] bg-[#fffefa] p-4 shadow-xl"
open
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-4 text-emerald-500" />
<h4 className="text-sm font-semibold text-[#20201d]">
GitHub Access
</h4>
</div>
<button
aria-label="Close settings"
className="text-[#858277] hover:text-[#20201d]"
onClick={() => setOpen(false)}
type="button"
>
<X className="size-3.5" />
</button>
</div>
{externalUsername ? (
<p className="mt-2 text-xs text-[#68665e]">
Connected as{" "}
<span className="font-medium text-[#20201d]">
{externalUsername}
</span>
</p>
) : null}
{grantedScopes.length > 0 ? (
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-[#858277]">
Granted scopes
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{grantedScopes.map((scope) => (
<span
className="rounded bg-[#f5f3eb] px-1.5 py-0.5 text-[10px] font-medium text-[#68665e]"
key={scope}
>
{scope}
</span>
))}
</div>
</div>
) : (
<p className="mt-2 text-xs text-[#858277]">
Scope details will appear after reauthorization.
</p>
)}
<p className="mt-3 text-xs leading-5 text-[#68665e]">
Reauthorization refreshes GitHub scopes and re-syncs your accessible
repositories, including organization repos GitHub returns for your
account.
</p>
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
Organization repository visibility depends on your GitHub org
membership and SSO authorization not all org repos may be
accessible via OAuth alone.
</p>
<Button
className="mt-3 w-full gap-1.5 rounded border-[#55564e] bg-[#20201d] text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
disabled={reauthorizing}
onClick={() => void handleReauthorize()}
size="sm"
type="button"
variant="outline"
>
{reauthorizing ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{reauthorizing ? "Redirecting…" : "Reauthorize access"}
</Button>
{missingScopes.length > 0 ? (
<p className="mt-2 text-[10px] leading-4 text-amber-600">
Not yet granted: {missingScopes.join(", ")}
</p>
) : null}
</dialog>
) : null}
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { LoaderCircle } from "lucide-react";
export const LoadingState = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
</main>
);

View File

@@ -0,0 +1,76 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { ProjectView } from "@code/primitives/project";
import {
ArrowUpRight,
BookOpen,
ChevronDown,
FolderGit2,
GitBranch,
} from "lucide-react";
import { Link } from "react-router";
import { ContextEditor } from "./context-editor";
const dateFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
export const ProjectCard = ({ project }: { readonly project: ProjectView }) => {
const [source] = project.sources;
const contextDocuments = project.contextDocuments.filter(
(document) => document.content.trim().length > 0
);
const repositoryLabel =
source?.repositoryPath ?? source?.host ?? "Repository";
return (
<article className="group flex min-w-0 flex-col border border-[#d7d3c7] bg-[#fffefa] transition-colors hover:border-[#aaa69a]">
<Link
className="flex min-w-0 flex-1 flex-col p-5 outline-none focus-visible:ring-2 focus-visible:ring-[#55564e] focus-visible:ring-inset"
to={`/?project=${project.id}`}
>
<div className="flex items-start justify-between gap-4">
<span className="grid size-10 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
<FolderGit2 className="size-4" />
</span>
<ArrowUpRight className="mt-1 size-4 shrink-0 text-[#858277] transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[#20201d]" />
</div>
<div className="mt-5 min-w-0">
<h3 className="truncate text-base font-semibold tracking-tight text-[#20201d]">
{project.name}
</h3>
<p className="mt-1 truncate text-sm text-[#68665e]">
{repositoryLabel}
</p>
</div>
<div className="mt-6 flex flex-wrap gap-x-4 gap-y-2 border-t border-[#e4e0d4] pt-4 text-xs text-[#747168]">
{source?.defaultBranch ? (
<span className="inline-flex items-center gap-1.5">
<GitBranch className="size-3.5" />
{source.defaultBranch}
</span>
) : null}
<span className="inline-flex items-center gap-1.5">
<BookOpen className="size-3.5" />
{contextDocuments.length} context{" "}
{contextDocuments.length === 1 ? "source" : "sources"}
</span>
</div>
<p className="mt-3 text-xs text-[#858277]">
Updated {dateFormatter.format(project.updatedAt)}
</p>
</Link>
<details className="border-t border-[#e4e0d4]">
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-3 text-xs font-medium text-[#68665e] marker:content-none hover:text-[#20201d] [&::-webkit-details-marker]:hidden">
Project context
<ChevronDown className="size-3.5 transition-transform [[open]_&]:rotate-180" />
</summary>
<div className="border-t border-[#e4e0d4] px-5 pb-5">
<ContextEditor projectId={project.id as unknown as Id<"projects">} />
</div>
</details>
</article>
);
};

View File

@@ -0,0 +1,65 @@
import type { ProjectView } from "@code/primitives/project";
import { Search } from "lucide-react";
import { ProjectCard } from "./project-card";
export const ProjectsGrid = ({
onSearchChange,
projects,
search,
totalProjects,
}: {
readonly onSearchChange: (value: string) => void;
readonly projects: readonly ProjectView[];
readonly search: string;
readonly totalProjects: number;
}) => (
<section aria-labelledby="projects-heading" className="mt-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex items-baseline gap-2">
<h2
className="text-lg font-semibold tracking-tight text-[#20201d]"
id="projects-heading"
>
Your projects
</h2>
<span className="text-xs tabular-nums text-[#858277]">
{totalProjects}
</span>
</div>
<p className="mt-1 text-sm text-[#68665e]">
Choose a repository to continue its project conversation.
</p>
</div>
<label className="relative block w-full sm:w-64">
<span className="sr-only">Search projects</span>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search projects"
type="search"
value={search}
/>
</label>
</div>
{projects.length > 0 ? (
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : (
<div className="mt-5 border border-dashed border-[#c9c5b9] bg-[#fffefa]/60 p-8 text-center">
<p className="text-sm font-medium text-[#20201d]">
No matching projects
</p>
<p className="mt-1 text-xs text-[#747168]">
Try a different project name or repository.
</p>
</div>
)}
</section>
);

View File

@@ -0,0 +1,51 @@
import { Button } from "@code/ui/components/button";
import { ArrowLeft, FolderGit2, Plus } from "lucide-react";
export const ProjectsHeader = ({
hasProjects,
onGoHome,
onOpenAddProject,
}: {
readonly hasProjects: boolean;
readonly onGoHome: () => void;
readonly onOpenAddProject: () => void;
}) => (
<header className="flex flex-col gap-5 border-b border-[#d7d3c7] pb-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
<FolderGit2 className="size-5" />
</span>
<div>
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Zopu workspace
</p>
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
Projects
</h1>
</div>
</div>
<div className="flex items-center gap-2">
{hasProjects ? (
<Button
className="border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
onClick={onGoHome}
size="lg"
type="button"
variant="outline"
>
<ArrowLeft className="size-3.5" />
Workspace
</Button>
) : null}
<Button
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
onClick={onOpenAddProject}
size="lg"
type="button"
>
<Plus className="size-4" />
Add project
</Button>
</div>
</header>
);

View File

@@ -0,0 +1,166 @@
import { authClient } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
CheckCircle2,
GitBranch,
LoaderCircle,
Server,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { GithubConnectionSettings } from "./github-connection-settings";
import { PuterConnectForm } from "./puter-connect-form";
export interface GitProviderAccountOption {
readonly externalUsername: string;
readonly grantedScopesJson?: string;
readonly id: string;
readonly provider: "github" | "gitea";
readonly serverUrl: string;
readonly status:
| "pending-auth"
| "active"
| "reauth-required"
| "revoked"
| "unavailable";
}
interface ProviderChipsProps {
readonly accounts: readonly GitProviderAccountOption[] | undefined;
}
interface ProviderChipProps {
readonly account: GitProviderAccountOption | undefined;
readonly icon: React.ElementType;
readonly label: string;
readonly loading: boolean;
readonly onClick: () => void;
}
const isHealthy = (status: GitProviderAccountOption["status"]) =>
status === "active";
const ProviderStatusIcon = ({ connected }: { readonly connected: boolean }) =>
connected ? (
<CheckCircle2 className="size-3 text-emerald-400" />
) : (
<XCircle className="size-3 text-amber-500" />
);
const ProviderChip = ({
account,
icon: Icon,
label,
loading,
onClick,
}: ProviderChipProps) => {
const connected = account !== undefined && isHealthy(account.status);
return (
<Button
aria-pressed={connected}
className={
connected
? "h-8 gap-1.5 rounded border-[#d7d3c7] bg-[#f5f3eb] px-3 text-[#20201d] hover:bg-[#f5f3eb]"
: "h-8 gap-1.5 rounded border-[#c9c5b9] bg-[#fffefa] px-3 text-[#68665e] hover:bg-[#f5f3eb]"
}
disabled={loading || connected}
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
{loading ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Icon className="size-3.5 text-[#747168]" />
)}
<span className="text-xs font-medium">
{connected ? account.externalUsername : `Connect ${label}`}
</span>
{account === undefined ? null : (
<ProviderStatusIcon connected={connected} />
)}
</Button>
);
};
export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
const [pendingProvider, setPendingProvider] = useState<
"github" | "gitea" | undefined
>();
const [showPuterForm, setShowPuterForm] = useState(false);
const githubAccount = accounts?.find(
(account) => account.provider === "github"
);
const puterAccount = accounts?.find(
(account) => account.provider === "gitea"
);
const linkGithub = async () => {
if (pendingProvider) {
return;
}
setPendingProvider("github");
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
});
} finally {
setPendingProvider(undefined);
}
};
const handlePuterChipClick = () => {
if (puterAccount && isHealthy(puterAccount.status)) {
return;
}
setShowPuterForm((value) => !value);
};
const handlePuterConnected = () => {
setShowPuterForm(false);
};
const accountsLoading = accounts === undefined;
const githubConnected =
githubAccount !== undefined && isHealthy(githubAccount.status);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<ProviderChip
account={githubAccount}
icon={GitBranch}
label="GitHub"
loading={accountsLoading || pendingProvider === "github"}
onClick={linkGithub}
/>
{githubConnected ? (
<GithubConnectionSettings
connectionId={githubAccount.id}
externalUsername={githubAccount.externalUsername}
grantedScopesJson={githubAccount.grantedScopesJson}
/>
) : null}
</div>
<ProviderChip
account={puterAccount}
icon={Server}
label="Puter Git"
loading={accountsLoading || pendingProvider === "gitea"}
onClick={handlePuterChipClick}
/>
</div>
{showPuterForm ? (
<div className="rounded border border-[#d7d3c7] bg-[#fffefa] p-3 shadow-sm">
<PuterConnectForm onConnected={handlePuterConnected} />
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Server } from "lucide-react";
import { useState } from "react";
import { GitHubConnectButton } from "./github-connect-button";
import { PuterConnectForm } from "./puter-connect-form";
export const ProviderSection = () => {
const [showPuterForm, setShowPuterForm] = useState(false);
return (
<section aria-labelledby="provider-connections-heading">
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Need a repository?
</p>
<h3
className="mt-1 text-base font-semibold text-[#20201d]"
id="provider-connections-heading"
>
Connect a Git provider
</h3>
<p className="mt-1 text-xs leading-5 text-[#68665e]">
Link GitHub for OAuth access or add a Puter Git personal access token.
</p>
<div className="mt-4 space-y-2">
<GitHubConnectButton />
<button
aria-expanded={showPuterForm}
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
onClick={() => setShowPuterForm((value) => !value)}
type="button"
>
<Server className="size-4 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>
);
};

View File

@@ -0,0 +1,80 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useAction } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
interface ConnectGiteaResult {
connectionId: Id<"gitConnections">;
}
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
ConnectGiteaResult
>("gitConnections:connectGitea");
export 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>
);
};

View File

@@ -0,0 +1,346 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import {
ArrowUpRight,
LoaderCircle,
FolderGit2,
GitBranch,
Globe2,
Lock,
Search,
Server,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
import { useRepositoryPicker } from "@/hooks/use-repository-picker";
import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
const listRepositoriesRef = makeFunctionReference<
"query",
Record<string, never>,
readonly GitRepositoryOption[]
>("gitProvisioning:listRepositories");
interface CreateProjectResult {
projectId: Id<"projects">;
}
const createProjectFromRepositoryRef = makeFunctionReference<
"mutation",
{ gitRepositoryId: Id<"gitRepositories">; name: string },
CreateProjectResult
>("gitProvisioning:createProjectFromRepository");
const providerLabels = {
all: "All",
gitea: "Puter Git",
github: "GitHub",
} as const;
export const RepositorySelector = ({
existingSourceUrls,
githubConnectionId,
onProjectCreated,
}: {
readonly existingSourceUrls: readonly string[];
readonly githubConnectionId: Id<"gitConnections"> | undefined;
readonly onProjectCreated: (projectId: string) => void;
}) => {
const repositories = useQuery(listRepositoriesRef, {});
const createProject = useMutation(createProjectFromRepositoryRef);
const [error, setError] = useState<string>();
const [pending, setPending] = useState<string>();
const {
availableRepositories,
privacyFilter,
providerFilter,
search,
setPrivacyFilter,
setProviderFilter,
setSearch,
} = useRepositoryPicker({
existingSourceUrls,
repositories,
});
// Live GitHub search: active when GitHub provider is selectable (filter is
// "all" or "github") and a GitHub connection exists. The search action uses
// the server-side encrypted credential — the token never reaches the browser.
const githubSearchEnabled =
githubConnectionId !== undefined &&
(providerFilter === "all" || providerFilter === "github");
const githubSearch = useGithubRepoSearch({
connectionId: githubConnectionId,
enabled: githubSearchEnabled,
query: search,
});
// Merge live search results with the synced snapshot. Live results take
// priority by webUrl to avoid duplicate rows.
const liveResults = useMemo<readonly GithubRepositorySearchResult[]>(() => {
if (githubSearch.kind !== "results") {
return [];
}
return githubSearch.results;
}, [githubSearch]);
const displayRepositories = useMemo<readonly GitRepositoryOption[]>(() => {
const base = availableRepositories ?? [];
if (liveResults.length === 0) {
return base;
}
const existingUrls = new Set(base.map((repo) => repo.webUrl));
const merged: GitRepositoryOption[] = [...base];
for (const result of liveResults) {
if (!result.gitRepositoryId || existingUrls.has(result.webUrl)) {
continue;
}
existingUrls.add(result.webUrl);
merged.push({
cloneUrl: result.cloneUrl,
defaultBranch: result.defaultBranch,
fullName: result.fullName,
id: result.gitRepositoryId,
name: result.name,
owner: result.owner,
privacy: result.privacy,
provider: result.provider,
webUrl: result.webUrl,
});
}
return merged.toSorted((left, right) =>
left.fullName.localeCompare(right.fullName)
);
}, [availableRepositories, liveResults]);
const create = async (repository: GitRepositoryOption) => {
if (pending) {
return;
}
setError(undefined);
setPending(repository.id);
try {
const result = await createProject({
gitRepositoryId: repository.id as Id<"gitRepositories">,
name: repository.name,
});
onProjectCreated(String(result.projectId));
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not create this project"
);
} finally {
setPending(undefined);
}
};
if (repositories === undefined || availableRepositories === undefined) {
return (
<div className="grid min-h-48 flex-1 place-items-center border border-[#d7d3c7] bg-[#fffefa]">
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
</div>
);
}
const isFiltered =
Boolean(search) || providerFilter !== "all" || privacyFilter !== "all";
return (
<section
aria-labelledby="repository-picker-heading"
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3
className="text-base font-semibold text-[#20201d]"
id="repository-picker-heading"
>
Imports
</h3>
<p className="mt-0.5 text-xs text-[#747168]">
Repositories already added as projects are hidden.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Link
className="inline-flex items-center gap-1 text-xs font-medium text-[#20201d] underline decoration-[#858277] underline-offset-4 hover:decoration-[#20201d]"
to="/?import=public"
>
Import public Git URL
<ArrowUpRight className="size-3" />
</Link>
<p className="text-xs tabular-nums text-[#858277]">
{availableRepositories.length} available
</p>
</div>
</div>
<div className="mt-4 grid shrink-0 gap-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between">
<label className="relative min-w-0 sm:max-w-[16rem]">
<span className="sr-only">Search repositories</span>
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
<input
className="h-9 w-full rounded border border-[#d7d3c7] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none ring-[#d7d3c7] placeholder:text-[#858277] focus:border-[#858277] focus:ring-1"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search repositories"
type="search"
value={search}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<fieldset className="flex flex-wrap gap-1">
<legend className="sr-only">Repository provider</legend>
{(
Object.keys(providerLabels) as (keyof typeof providerLabels)[]
).map((provider) => (
<Button
aria-pressed={providerFilter === provider}
className={
providerFilter === provider
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
}
key={provider}
onClick={() => setProviderFilter(provider)}
type="button"
variant="outline"
>
{providerLabels[provider]}
</Button>
))}
</fieldset>
<fieldset className="flex flex-wrap gap-1">
<legend className="sr-only">Repository visibility</legend>
{(["all", "private", "public"] as const).map((privacy) => (
<Button
aria-pressed={privacyFilter === privacy}
className={
privacyFilter === privacy
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
}
key={privacy}
onClick={() => setPrivacyFilter(privacy)}
type="button"
variant="outline"
>
{privacy === "all" ? "All" : privacy}
</Button>
))}
</fieldset>
</div>
</div>
{error ? (
<p className="mt-3 border border-red-300 bg-red-50 p-2.5 text-xs leading-5 text-red-700">
{error}
</p>
) : null}
{githubSearch.kind === "loading" ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-[#747168]">
<LoaderCircle className="size-3 animate-spin" />
Searching GitHub
</p>
) : null}
{githubSearch.kind === "error" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
{githubSearch.message}
</p>
) : null}
{githubSearch.kind === "reauth" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
GitHub access needs reauthorization. Use the settings control on the
GitHub chip to reconnect.
</p>
) : null}
{displayRepositories.length > 0 ? (
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
{displayRepositories.map((repository) => {
const ProviderIcon =
repository.provider === "github" ? GitBranch : Server;
const VisibilityIcon =
repository.privacy === "private" ? Lock : Globe2;
return (
<div
className="flex items-center justify-between gap-3 p-3"
key={repository.id}
>
<div className="flex min-w-0 items-start gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded border border-[#d7d3c7] bg-[#f5f3eb] text-[#68665e]">
<ProviderIcon className="size-3.5" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[#20201d]">
{repository.fullName}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-[#747168]">
<span>{repository.defaultBranch}</span>
<span className="inline-flex items-center gap-1">
<VisibilityIcon className="size-3" />
{repository.privacy}
</span>
</div>
</div>
</div>
<Button
className="h-7 shrink-0 rounded border-[#55564e] bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
disabled={pending !== undefined}
onClick={() => void create(repository)}
type="button"
variant="outline"
>
{pending === repository.id ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<FolderGit2 className="size-3" />
)}
{pending === repository.id ? "Creating" : "Add"}
</Button>
</div>
);
})}
</div>
) : (
<div className="mt-3 rounded border border-dashed border-[#c9c5b9] bg-[#fffefa] p-6 text-center">
<FolderGit2 className="mx-auto size-5 text-[#858277]" />
<p className="mt-3 text-sm font-medium text-[#20201d]">
{isFiltered
? "No repositories match these filters"
: "No repositories available"}
</p>
<p className="mt-1 text-xs leading-5 text-[#68665e]">
{isFiltered
? "Change your search or filters to see other repositories."
: "Connect a Git provider or import a public repository to continue."}
</p>
{isFiltered ? (
<Button
className="mt-4 border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
onClick={() => {
setPrivacyFilter("all");
setProviderFilter("all");
setSearch("");
}}
size="sm"
type="button"
variant="outline"
>
Clear filters
</Button>
) : null}
</div>
)}
</section>
);
};

View File

@@ -1,11 +1,11 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import * as React from "react";
export function ThemeProvider({
export const ThemeProvider = ({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
}: React.ComponentProps<typeof NextThemesProvider>) => (
<NextThemesProvider {...props}>{children}</NextThemesProvider>
);
export { useTheme } from "next-themes";

View File

@@ -79,7 +79,7 @@ export const ConversationComposer = ({
</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]"
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) => {

View File

@@ -1,11 +1,8 @@
import { Button } from "@code/ui/components/button";
import { Menu, Settings } from "lucide-react";
import { useState } from "react";
import { FolderGit2, Menu } from "lucide-react";
import { Link } from "react-router";
import type { WorkspaceState } from "@/lib/workspace/types";
import { ProjectSettingsPanel } from "./project-settings-panel";
export const ProjectHeader = ({
onOpenDrawer,
workspace,
@@ -13,11 +10,10 @@ export const ProjectHeader = ({
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">
<header className="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"
@@ -35,15 +31,13 @@ export const ProjectHeader = ({
Conversation to proposed Work
</p>
</div>
<Button
aria-label="Workspace settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
<Link
aria-label="Projects"
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
to="/projects"
>
<Settings className="size-4" />
</Button>
<FolderGit2 className="size-4" />
</Link>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={onOpenDrawer}
@@ -52,12 +46,6 @@ export const ProjectHeader = ({
<Menu className="size-4" /> Work{" "}
{workspace.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<ProjectSettingsPanel
onClose={() => setSettingsOpen(false)}
workspace={workspace}
/>
) : null}
</header>
);
};

View File

@@ -1,100 +0,0 @@
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 [serverUrl, setServerUrl] = useState("https://git.openputer.com");
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="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setServerUrl(event.target.value)}
value={serverUrl}
/>
<input
aria-label="Gitea 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({
serverUrl,
token,
username: username || undefined,
});
setToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
);
};

View File

@@ -1,7 +1,6 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
Check,
ChevronRight,
FileCode2,
Hammer,
@@ -32,16 +31,13 @@ const starterDefinition = (work: WorkRecord) => ({
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
requiredArtifacts: ["Simulation activity and terminal outcome"],
requiredArtifacts: ["Implementation diff 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",
],
architectureSummary: "Implement the requested outcome in focused changes.",
callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
@@ -49,30 +45,30 @@ const starterDesign = (work: WorkRecord) => ({
files: [],
modules: [],
risks: [],
summary: "Compact vertical-slice simulation",
summary: "Focused implementation",
},
invariants: [
"Simulation never claims implementation",
"A successful AgentOS response alone never marks Work complete",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
keyInterfaces: ["WorkExecution", "AttemptResult"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "deterministic-simulation",
id: "implementation",
objective: work.objective,
observableBehavior: "A terminal fake Run is visible",
observableBehavior: "Implementation changes are visible in the diff",
reviewRequired: false,
title: "Deterministic simulation",
title: "Implementation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
tradeoffs: [],
});
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
// oxlint-disable-next-line complexity -- this card coordinates planning and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
@@ -95,7 +91,7 @@ export const WorkCard = ({
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
{work.status === "proposed" ? "Proposed Work" : "Work"}
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
@@ -149,7 +145,8 @@ export const WorkCard = ({
Outcome
</p>
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
<p className="mt-2 text-[#747168]">
<p className="mt-2 text-[#747168]">Status: {work.status}</p>
<p className="mt-1 text-[#747168]">
Risk: {work.definition?.risk ?? "not defined"}
</p>
{openQuestions > 0 ? (
@@ -180,20 +177,6 @@ export const WorkCard = ({
<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>
@@ -220,22 +203,6 @@ export const WorkCard = ({
<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>
@@ -261,10 +228,7 @@ export const WorkCard = ({
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
Run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
@@ -343,45 +307,29 @@ export const WorkCard = ({
)}
<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>
</>
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void (latestRun.executionKind === "real"
? workspace.cancelExecution(latestRun._id)
: workspace.cancelSimulation(latestRun._id))
}
onClick={() => void workspace.cancelExecution(latestRun._id)}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.executionKind !== "real" &&
latestRun?.status === "terminal" &&
{latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.retrySimulation(latestRun._id)}
onClick={() => void workspace.retryExecution(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>

View File

@@ -0,0 +1,25 @@
import type { ProjectView } from "@code/primitives/project";
import { useMemo } from "react";
export const useFilteredProjects = ({
projects,
search,
}: {
readonly projects: readonly ProjectView[];
readonly search: string;
}) =>
useMemo(() => {
const searchTerm = search.trim().toLowerCase();
if (!searchTerm) {
return projects;
}
return projects.filter((project) => {
const [source] = project.sources;
return (
project.name.toLowerCase().includes(searchTerm) ||
source?.repositoryPath.toLowerCase().includes(searchTerm) ||
source?.host.toLowerCase().includes(searchTerm)
);
});
}, [projects, search]);

View File

@@ -0,0 +1,102 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react";
export type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
export type GithubSearchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "results"; results: readonly GithubRepositorySearchResult[] }
| { kind: "error"; message: string }
| { kind: "reauth" };
const MIN_QUERY_LENGTH = 2;
const DEBOUNCE_MS = 350;
/**
* Debounced live GitHub repository search. Calls the
* `gitConnections:searchGithubRepositories` action with the user's stored
* encrypted credential (the token never reaches the browser). Returns a
* stale-safe, cancellable search state.
*
* Only searches when the GitHub provider filter is active (or "all") and a
* query is at least `MIN_QUERY_LENGTH` characters.
*/
export const useGithubRepoSearch = ({
connectionId,
enabled,
query,
}: {
readonly connectionId: Id<"gitConnections"> | undefined;
readonly enabled: boolean;
readonly query: string;
}): GithubSearchState => {
const searchAction = useAction(api.gitConnections.searchGithubRepositories);
const [state, setState] = useState<GithubSearchState>({ kind: "idle" });
// Track the latest request so stale responses are discarded.
const requestIdRef = useRef(0);
const trimmed = query.trim();
const shouldBeSearching =
enabled && connectionId !== undefined && trimmed.length >= MIN_QUERY_LENGTH;
useEffect(() => {
if (!shouldBeSearching || !connectionId) {
return;
}
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
// eslint-disable-next-line react-compiler/react-compiler -- loading state must be set synchronously before the debounced fetch
setState({ kind: "loading" });
const timer = setTimeout(() => {
void (async () => {
try {
const results = await searchAction({
connectionId,
query: trimmed,
});
// Discard if a newer request superseded this one.
if (requestIdRef.current !== currentRequestId) {
return;
}
setState({ kind: "results", results });
} catch (caughtError) {
if (requestIdRef.current !== currentRequestId) {
return;
}
const message =
caughtError instanceof Error
? caughtError.message
: "GitHub search failed";
if (
message.toLowerCase().includes("reauth") ||
message.toLowerCase().includes("rejected") ||
message.toLowerCase().includes("reconnect")
) {
setState({ kind: "reauth" });
} else {
setState({ kind: "error", message });
}
}
})();
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
};
}, [connectionId, shouldBeSearching, searchAction, trimmed]);
// Reset to idle when search is no longer active.
useEffect(() => {
if (!shouldBeSearching && state.kind !== "idle") {
// eslint-disable-next-line react-compiler/react-compiler -- reset state when search is no longer active
setState({ kind: "idle" });
}
}, [shouldBeSearching, state.kind]);
return state;
};

View File

@@ -0,0 +1,22 @@
import { useCallback, useState } from "react";
export const useProjectsPageState = ({
hasProjects,
}: {
readonly hasProjects: boolean | undefined;
}) => {
const [addProjectOpen, setAddProjectOpen] = useState<boolean>();
const [projectSearch, setProjectSearch] = useState("");
const isAddProjectOpen = addProjectOpen ?? hasProjects === false;
const closeAddProject = useCallback(() => setAddProjectOpen(false), []);
const openAddProject = useCallback(() => setAddProjectOpen(true), []);
return {
closeAddProject,
isAddProjectOpen,
openAddProject,
projectSearch,
setProjectSearch,
};
};

View File

@@ -0,0 +1,64 @@
import { useMemo, useState } from "react";
export interface GitRepositoryOption {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly fullName: string;
readonly id: string;
readonly name: string;
readonly owner: string;
readonly privacy: "public" | "private";
readonly provider: "github" | "gitea";
readonly webUrl: string;
}
type ProviderFilter = "all" | GitRepositoryOption["provider"];
type PrivacyFilter = "all" | GitRepositoryOption["privacy"];
export const useRepositoryPicker = ({
existingSourceUrls,
repositories,
}: {
readonly existingSourceUrls: readonly string[];
readonly repositories: readonly GitRepositoryOption[] | undefined;
}) => {
const [privacyFilter, setPrivacyFilter] = useState<PrivacyFilter>("all");
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
const [search, setSearch] = useState("");
const availableRepositories = useMemo(() => {
if (!repositories) {
return;
}
const existingUrls = new Set(existingSourceUrls);
const searchTerm = search.trim().toLowerCase();
return repositories
.filter((repository) => !existingUrls.has(repository.webUrl))
.filter(
(repository) =>
providerFilter === "all" || repository.provider === providerFilter
)
.filter(
(repository) =>
privacyFilter === "all" || repository.privacy === privacyFilter
)
.filter(
(repository) =>
!searchTerm ||
repository.fullName.toLowerCase().includes(searchTerm) ||
repository.owner.toLowerCase().includes(searchTerm)
)
.toSorted((left, right) => left.fullName.localeCompare(right.fullName));
}, [existingSourceUrls, privacyFilter, providerFilter, repositories, search]);
return {
availableRepositories,
privacyFilter,
providerFilter,
search,
setPrivacyFilter,
setProviderFilter,
setSearch,
};
};

View File

@@ -4,6 +4,7 @@ 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";
@@ -12,13 +13,6 @@ import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
type ExecutionScenario =
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
const workListRef = makeFunctionReference<
"query",
{ projectId: Id<"projects"> },
@@ -34,46 +28,26 @@ const saveDefinitionRef = makeFunctionReference<
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDefinitionProposal");
const approveDefinitionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; version: number },
unknown
>("workPlanning:approveDefinition");
const saveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; payloadJson: string },
unknown
>("workPlanning:saveDesignProposal");
const approveDesignRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
unknown
>("workPlanning:approveDesign");
const startSimulationRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
unknown
>("workExecution:startSimulatedExecution");
const cancelSimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:cancelSimulatedExecution");
const retrySimulationRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retrySimulatedExecution");
const startExecutionRef = makeFunctionReference<
"mutation",
{ workId: Id<"works">; sliceId?: string },
{ sliceId?: string; workId: Id<"works"> },
unknown
>("workExecutionWorkflow:startExecution");
>("workExecution:start");
const cancelExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecutionWorkflow:cancelExecution");
>("workExecution:cancel");
const retryExecutionRef = makeFunctionReference<
"mutation",
{ runId: Id<"workRuns"> },
unknown
>("workExecution:retry");
const listGitConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
@@ -96,7 +70,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<
@@ -118,6 +92,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
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("");
@@ -125,11 +100,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
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,
@@ -145,14 +126,10 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
const requestDefinitionMutation = useMutation(requestDefinitionRef);
const saveDefinitionMutation = useMutation(saveDefinitionRef);
const approveDefinitionMutation = useMutation(approveDefinitionRef);
const saveDesignMutation = useMutation(saveDesignRef);
const approveDesignMutation = useMutation(approveDesignRef);
const startSimulationMutation = useMutation(startSimulationRef);
const cancelSimulationMutation = useMutation(cancelSimulationRef);
const retrySimulationMutation = useMutation(retrySimulationRef);
const startExecutionMutation = useMutation(startExecutionRef);
const cancelExecutionMutation = useMutation(cancelExecutionRef);
const retryExecutionMutation = useMutation(retryExecutionRef);
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
const connectGiteaAction = useAction(connectGiteaRef);
const connectGithubAction = useAction(connectGithubRef);
@@ -202,11 +179,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
});
};
const connectGitea = (input: {
serverUrl: string;
token: string;
username?: string;
}) =>
const connectGitea = (input: { token: string; username?: string }) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
@@ -220,22 +193,13 @@ export const useProjectWorkspace = (): WorkspaceState => {
return {
agent,
approveDefinition: (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId }),
approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.signIn.social({
callbackURL: window.location.href,
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,
@@ -249,8 +213,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
repository,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
retrySimulation: (runId: Id<"workRuns">) =>
runOperation(() => retrySimulationMutation({ runId })),
retryExecution: (runId: Id<"workRuns">) =>
runOperation(() => retryExecutionMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
@@ -263,14 +227,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
setRepository,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
startSimulation: (
workId: Id<"works">,
scenario: ExecutionScenario,
sliceId?: string
) =>
runOperation(() =>
startSimulationMutation({ scenario, sliceId, workId })
),
works,
};
};

View File

@@ -56,4 +56,39 @@ describe("projectConversation", () => {
{ state: "done", text: "Captured the request.", type: "text" },
]);
});
test("clears a historical failure after a newer turn succeeds", () => {
const state = projectConversation([
row({
error: "Old admission failure",
messageId: "assistant-failed",
role: "assistant",
status: "failed",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.failedError).toBeUndefined();
});
test("clears historical pending state after a newer turn completes", () => {
const state = projectConversation([
row({
messageId: "assistant-stale",
role: "assistant",
status: "running",
}),
row({
messageId: "assistant-completed",
rawText: "Recovered",
role: "assistant",
status: "completed",
}),
]);
expect(state.pending).toBe(false);
});
});

View File

@@ -20,7 +20,18 @@ export interface ConversationRow {
| "running";
}
const latestAssistant = (
rows: readonly ConversationRow[]
): ConversationRow | undefined =>
rows.findLast((row) => row.role === "assistant");
const latestTerminalError = (row: ConversationRow | undefined) =>
row?.status === "failed"
? (row.error ?? "Conversation turn failed")
: undefined;
export const projectConversation = (rows: readonly ConversationRow[]) => {
const activeAssistant = latestAssistant(rows);
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
@@ -28,9 +39,7 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
row.rawText.length > 0
);
return {
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
failedError: latestTerminalError(activeAssistant),
messages: rows
.filter(
(row) =>
@@ -65,13 +74,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
],
role: row.role,
})),
pending: rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" ||
row.status === "dispatching" ||
row.status === "running")
),
pending:
activeAssistant?.status === "queued" ||
activeAssistant?.status === "dispatching" ||
activeAssistant?.status === "running",
streaming,
};
};

View File

@@ -60,12 +60,6 @@ describe("Workspace frontend regression contracts", () => {
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
test("exposes an accessible label on the workspace settings button", () => {
const header = source("../../components/workspace/project-header.tsx");
expect(header).toContain('aria-label="Workspace settings"');
});
test("keeps markdown readable on the forced-dark workspace surface", () => {
const styles = source("../../index.css");

View File

@@ -9,7 +9,6 @@ export interface WorkRecord {
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 }[];
@@ -34,7 +33,6 @@ export interface WorkRun {
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
@@ -81,21 +79,10 @@ export interface WorkspaceState {
| "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 serverUrl: string;
readonly token: string;
readonly username?: string;
}) => Promise<void>;
@@ -109,7 +96,7 @@ export interface WorkspaceState {
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly retryExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
@@ -125,16 +112,6 @@ export interface WorkspaceState {
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;
}

View File

@@ -6,5 +6,8 @@ export default [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
layout("./routes/app/layout.tsx", [
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig;

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

@@ -0,0 +1,125 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { AddProjectPanel } from "@/components/projects/add-project-panel";
import { LoadingState } from "@/components/projects/loading-state";
import { ProjectsGrid } from "@/components/projects/projects-grid";
import { ProjectsHeader } from "@/components/projects/projects-header";
import type { GitProviderAccountOption } from "@/components/projects/provider-chips";
import { useFilteredProjects } from "@/hooks/use-filtered-projects";
import { useProjectsPageState } from "@/hooks/use-projects-page-state";
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
export default function ProjectsRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const providerAccounts = useQuery(
api.gitProvisioning.listProviderAccounts,
{}
) as readonly GitProviderAccountOption[] | undefined;
const connectGithubAction = useAction(connectGithubRef);
const [resumeError, setResumeError] = useState<string>();
const pageState = useProjectsPageState({
hasProjects: projects === undefined ? undefined : projects.length > 0,
});
const { openAddProject } = pageState;
const handleProjectSearchChange = (value: string) => {
pageState.setProjectSearch(value);
};
const filteredProjects = useFilteredProjects({
projects: projects ?? [],
search: pageState.projectSearch,
});
useEffect(() => {
if (searchParams.get("resume") !== "github") {
return;
}
void (async () => {
try {
await connectGithubAction({});
setSearchParams({}, { replace: true });
openAddProject();
} catch (caughtError) {
setResumeError(
caughtError instanceof Error
? caughtError.message
: "GitHub connection failed"
);
}
})();
}, [connectGithubAction, openAddProject, searchParams, setSearchParams]);
if (projects === undefined) {
return <LoadingState />;
}
const existingSourceUrls = projects.flatMap((project) =>
project.sources.map((source) => source.url)
);
const hasProjects = projects.length > 0;
const handleProjectCreated = (projectId: string) => {
void navigate(`/?project=${projectId}`, { replace: true });
};
const handleCloseAddProject = () => {
pageState.closeAddProject();
};
return (
<main className="min-h-svh bg-[#f2f0e7] px-5 py-7 text-[#20201d] sm:px-8 sm:py-10">
<div className="mx-auto max-w-6xl">
<ProjectsHeader
hasProjects={hasProjects}
onGoHome={() => void navigate("/")}
onOpenAddProject={openAddProject}
/>
{resumeError ? (
<p className="mt-6 border border-red-300 bg-red-50 p-3 text-xs leading-5 text-red-700">
{resumeError}
</p>
) : null}
{hasProjects ? (
<ProjectsGrid
onSearchChange={handleProjectSearchChange}
projects={filteredProjects}
search={pageState.projectSearch}
totalProjects={projects.length}
/>
) : (
<section className="mt-10 max-w-2xl">
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
Begin here
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-[#20201d]">
Connect the repository where work happens.
</h2>
<p className="mt-3 max-w-xl text-base leading-7 text-[#68665e]">
Every project has its own conversation, durable context, and work
history. Start by choosing a repository.
</p>
</section>
)}
</div>
{pageState.isAddProjectOpen ? (
<AddProjectPanel
accounts={providerAccounts}
existingSourceUrls={existingSourceUrls}
onClose={handleCloseAddProject}
onProjectCreated={handleProjectCreated}
/>
) : null}
</main>
);
}

View File

@@ -2,22 +2,27 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus";
import { defineConfig, loadEnv } from "vite-plus";
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: "https://befitting-dalmatian-161.convex.site",
export default defineConfig(({ mode }) => {
const envDir = path.resolve(import.meta.dirname, "../..");
const env = loadEnv(mode, envDir, "");
return {
envDir,
plugins: [tailwindcss(), reactRouter()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: env.CONVEX_SITE_URL,
},
},
},
},
};
});

63
deploy/compose/Caddyfile Normal file
View File

@@ -0,0 +1,63 @@
# Caddy reverse proxy for the Zopu VDS staging agent stack.
#
# This is the ONLY public ingress to the private worker (see
# docs/DEPLOYMENT_PLAN.md §"Public surface"). It terminates TLS for the Flue
# callback hostname that Convex reaches, then forwards ONLY the documented agent
# worker paths to the internal `agents` service.
#
# Hard rules this file enforces:
# - No generic /api/* proxy and no browser-to-Flue traffic (DEPLOYMENT_PLAN.md
# line 86). Browsers talk only to Convex.
# - Only the Flue Node worker paths Convex actually calls are routed:
# /agents/zopu/<organizationId> conversation admission
# /internal/work-attempts/* work attempt execute + cancel
# /api/rivet/* RivetKit Actor gateway surface
# /workflows/* Flue workflow execution
# /health liveness probe (Compose healthcheck/CI)
# - Everything else returns 404. The private worker protocol stays narrow.
#
# Variables are injected by the `caddy` Compose service environment:
# {$AGENTS_HOST} public hostname, e.g. agents-staging.example.com
# {$AGENTS_UPSTREAM} internal upstream host:port, e.g. agents:3000
# {$ACME_EMAIL} email for the Let's Encrypt account
#
# TLS certs and the ACME account persist in the caddy-data volume. Caddy serves
# its own ACME HTTP-01 challenge responses on :80 automatically, so the :80
# block below only redirects everything else to HTTPS — worker traffic is never
# served over plain HTTP.
{
email {$ACME_EMAIL}
}
# --- TLS termination for the Flue callback hostname ---------------------------
{$AGENTS_HOST} {
encode zstd gzip
# Conversation admission: Convex POSTs to /agents/zopu/<organizationId>.
# The conversationRoute middleware requires the FLUE_DB_TOKEN bearer and the
# matching x-zopu-organization-id header.
reverse_proxy /agents/zopu/* {$AGENTS_UPSTREAM}
# Internal work-attempt execute/cancel. Requires the internalRoute bearer.
reverse_proxy /internal/work-attempts/* {$AGENTS_UPSTREAM}
# RivetKit Actor gateway surface (app.all("/api/rivet/*")). Routed so the
# runtime registry handler is reachable for actor metadata/start.
reverse_proxy /api/rivet/* {$AGENTS_UPSTREAM}
# Flue workflow execution (workflows/plan-work). Requires the workflowRoute
# bearer + x-zopu-organization-id header.
reverse_proxy /workflows/* {$AGENTS_UPSTREAM}
# Caddy routes the liveness request to the actual worker; it must not mask a
# failed worker process with a synthetic successful response.
reverse_proxy /health {$AGENTS_UPSTREAM}
# Everything else is not part of the private worker protocol.
respond 404
}
# --- HTTP → HTTPS redirect (Caddy still answers ACME challenges on :80) -------
:80 {
redir https://{host}{uri} permanent
}

View File

@@ -0,0 +1,55 @@
# Zopu VDS staging — Rivet engine + runner on host network.
#
# The agents registry and Caddy run on the HOST via systemd (not Docker).
# This compose only manages the two Rivet services, both on host networking:
#
# engine — Rivet Engine (RocksDB backend), binds 127.0.0.1:6420 on the host.
# UFW (policy DROP, only 22/80/443 open) keeps it private.
# runner — AgentOS runner, reaches the engine at 127.0.0.1:6420.
#
# With network_mode: host there are no Docker bridge networks, no port
# forwarding, and no host.docker.internal gymnastics — both services share
# the host network namespace directly.
#
# Usage:
# docker compose --env-file .env up -d
services:
engine:
image: ${ENGINE_IMAGE:?ENGINE_IMAGE must be set}
restart: unless-stopped
network_mode: host
volumes:
- type: bind
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/data/rivet
target: /data
environment:
RIVET__FILE_SYSTEM__PATH: /data
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}
RIVET_LOG_LEVEL: ${RIVET_LOG_LEVEL:-info}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:6420/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
runner:
image: ${RUNNER_IMAGE:?RUNNER_IMAGE must be set}
restart: unless-stopped
network_mode: host
environment:
NODE_ENV: production
RIVET_RUNNER_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
RIVET_ENVOY_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
RIVET_ENDPOINT: http://${RIVET_NAMESPACE:-default}:${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}@127.0.0.1:6420
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN:?RIVET_WORKSPACE_TOKEN is required}
AGENT_WORKSPACE_ROOT: /var/lib/zopu/workspaces
BUN_EXECUTABLE: /usr/local/bin/bun
volumes:
- type: bind
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/workspaces
target: /var/lib/zopu/workspaces
depends_on:
engine:
condition: service_healthy

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://rivet.dev/engine-config-schema.json",
"file_system": {
"path": "/data"
},
"singleplayer": false
}

17
deploy/systemd/Caddyfile Normal file
View File

@@ -0,0 +1,17 @@
{
email ops@zopu.ai
}
{$AGENTS_HOST} {
encode zstd gzip
route {
reverse_proxy /agents/zopu/* 127.0.0.1:3000
reverse_proxy /internal/work-attempts/* 127.0.0.1:3000
reverse_proxy /workflows/* 127.0.0.1:3000
reverse_proxy /health 127.0.0.1:3000
respond 404
}
}
:80 {
redir https://{host}{uri} permanent
}

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Zopu agent registry
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
User=zopu
Group=zopu
WorkingDirectory=/srv/zopu/source/packages/agents
EnvironmentFile=/etc/zopu/agents.env
ExecStart=/opt/zopu/node/bin/node dist/server.mjs
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/srv/zopu/workspaces
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,4 @@
[Service]
EnvironmentFile=/etc/zopu/agents.env
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile

168
docs/DEPLOYMENT_PLAN.md Normal file
View File

@@ -0,0 +1,168 @@
# Deployment Architecture Recommendation
> **Status:** Proposed — research completed 2026-07-31. This supersedes the shared-Convex-environment approach in `docs/deployment.md` once implemented.
>
> **Goal:** retain an instant local iteration loop while operating one stable public staging environment with reproducible, declarative infrastructure.
## Decision
Use a deliberately split stack:
```text
Fast iteration (private development) Stable staging (public)
──────────────────────────────────── ────────────────────────
Mac + Tailscale Vercel + Contabo VDS
Web / Flue / Rivet / runner Web SSR Agents VDS
│ │ │
personal Convex dev deployment staging Convex deployment
```
| Concern | Iteration | Staging |
| --- | --- | --- |
| User-visible URL | Existing Tailscale URL on the Mac | `https://staging.<domain>` on Vercel |
| Product backend | Personal Convex **dev** deployment | Dedicated long-lived Convex deployment |
| Web | Vite HMR on the Mac | Vercel React Router SSR |
| Agent worker | Local Flue, engine, and runner | Contabo VDS: Flue, Rivet Engine, AgentOS runner |
| Delivery | Run local `pnpm dev:tailscale` | CI applies IaC then updates immutable images |
| State/credentials | Development-only | Independent staging secrets, OAuth app, and volumes |
**Do not create a second remote “fast iteration” stack.** It duplicates the slowest part of the loop—building and replacing container images—while the existing Mac/Tailscale stack already exercises the complete topology from a phone. Staging is the public link and integration gate; local development is the fast environment.
## Why this split
The repository has three materially different runtime needs:
1. **Web** is a React Router SSR Node application. Its current production artifact is `react-router build` plus `react-router-serve`, and `apps/web/Dockerfile` already serves it on Node. Vercel supports React Router SSR and streaming, and is the appropriate managed host for this stateless application. [Vercel React Router guide](https://vercel.com/docs/frameworks/frontend/react-router)
2. **Convex** owns authentication, durable product data, workflows, and reactive client projections. It is not an application container to run on the VDS. The architecture explicitly requires clients to communicate only with Convex. (`docs/TECH.md`, §1.)
3. **Flue + Rivet Engine + AgentOS runner** are persistent worker processes. The runner creates Git worktrees, installs dependencies, and host-mounts those directories into AgentOS; they require a long-lived filesystem and must stay co-located with their workspace volume. They are not appropriate for Vercel or Cloudflare Workers. [Flue Node Docker deployment](https://flueframework.com/docs/ecosystem/deploy/docker) · [Rivet runtime modes](https://rivet.dev/docs/general/runtime-modes)
Cloudflare remains valuable for authoritative DNS, TLS/DDoS controls, and optionally a later public-edge layer. It is **not** the first frontend runtime choice: moving the current Node SSR artifact to Workers requires a Cloudflare-specific React Router/workerd build and `nodejs_compat`; it does not host the private worker stack. [Cloudflare React Router guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/)
## Environment isolation — required, not optional
The present shared Convex deployment is incompatible with two simultaneously operating environments. `SITE_URL` is a single deployment-scoped Better Auth base/trusted origin and GitHub OAuth callback origin; switching it between hosts breaks the other host (`docs/deployment.md`, lines 1947).
Create these independent Convex deployments:
| Deployment | Type | Purpose |
| --- | --- | --- |
| `dev:<developer>` | Convex dev | Local loop only; each developer owns one |
| `staging` | Long-lived production-type deployment | Public integration/staging; never reused for local testing |
| Branch previews | Ephemeral preview deployment | Optional later, only for frontend/backend changes that do not need OAuth |
Convex supports a named long-lived production-type deployment (`convex deployment create staging --type prod`), per-deployment environment variables, and branch preview deployments. [Convex multiple deployments](https://docs.convex.dev/production/multiple-deployments) · [Convex environment variables](https://docs.convex.dev/production/environment-variables)
Every environment gets its own:
- `SITE_URL`, exact browser origin;
- `FLUE_URL` / `AGENT_BACKEND_URL`, pointing at only that environments Flue worker;
- `FLUE_DB_TOKEN` shared only with that environments agent service;
- model and Git provider credentials;
- GitHub OAuth application/client credentials where GitHub login is enabled.
A GitHub OAuth App permits one callback URL. Use a staging OAuth App with `https://staging.<domain>/api/auth/callback/github`; keep local development on its own OAuth app or login mechanism. Do not promise OAuth on throwaway Vercel preview domains. [GitHub OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
## Staging topology
```mermaid
flowchart LR
Browser[Browser] -->|HTTPS| Vercel[Vercel: staging web SSR]
Browser -->|queries & mutations| Convex[Convex: staging]
Vercel -->|/api/auth rewrite| ConvexSite[Convex Site auth routes]
Convex -->|service-authenticated HTTPS| Flue[Contabo: Flue Node service]
Flue --> Engine[Rivet Engine: private Docker network]
Runner[AgentOS runner: private Docker network] --> Engine
Runner --> Volumes[Persistent source mirror + workspaces]
CF[Cloudflare DNS] --> Vercel
CF --> Flue
```
### Public surface
- `staging.<domain>` → Vercel.
- `agents-staging.<domain>` → Contabo Caddy/Traefik → Flue only.
- Only ports 80/443 (and Tailscale/managed SSH) enter the VDS. Do **not** publish Rivet ports `6420` or `6421`, the AgentOS runner, workspace directories, or any engine dashboard.
- The Flue route is reachable from Convex, but its middleware must continue to require the per-environment bearer `FLUE_DB_TOKEN` and organization/turn correlation headers. This is a private worker protocol exposed narrowly for Convex callbacks—not a browser API.
- Browser clients continue to talk only to Convex. Do not restore browser-to-Flue traffic or a generic `/api/*` agent proxy.
### Private VDS services
The checked-in Compose topology should contain exactly these services:
| Service | Network exposure | Persistent data | Notes |
| --- | --- | --- | --- |
| `engine` | Internal only | `/data` bind mount | Single-node RocksDB is suitable for staging. Set an admin token; probe `:6420/health`. |
| `agents` | Caddy upstream only | Shared workspace bind mount | Runs the existing Flue Node build and owns worktree preparation; `/health` is a liveness endpoint. |
| `runner` | Internal only | Shared workspace bind mount | Separate from `agents`; needs Bun, Git, and the same worktree paths prepared by `agents`. |
| `caddy` | 80/443 only | Caddy certificate/config bind mounts | TLS for the Flue callback hostname. |
Rivets filesystem backend is explicitly appropriate for single-node deployments; multi-node/HA later requires PostgreSQL and NATS. Configure the engine with a persistent `/data` bind mount, admin token, resource limits, and a `:6420/health` probe. [Rivet Docker Compose](https://rivet.dev/docs/self-hosting/docker-compose) · [Rivet production checklist](https://rivet.dev/docs/self-hosting/production-checklist)
The runner must receive a **build-time** `RIVET_RUNNER_VERSION` derived from the CI run or immutable release revision. Rivet uses it to route new actors to the new runner and drain old actors; without it, existing actors can continue on old code.
## IaC model: Pulumi + Ansible + Compose, each at the right seam
Dokploy is intentionally excluded: it has already proved too slow and imperative for this stack. One tool should not be forced to manage three different concerns poorly.
| Layer | Source of truth | Tool | Reason |
| --- | --- | --- | --- |
| SaaS control plane | `infra/pulumi` | **Pulumi TypeScript** | Declarative Cloudflare DNS and Vercel project/domain/environment configuration; one `staging` stack now, later `production`; encrypted stack secrets and `preview`/`refresh` drift workflows. |
| VDS baseline | `infra/ansible` | **Ansible** | Idempotent OS convergence: service user, Docker, firewall, Tailscale, directories, Caddy prerequisites, and backup timer. No Pulumi SSH-command pseudo-provider. |
| VDS application topology | `deploy/compose` | **Docker Compose** | Explicit services, networks, volumes, healthchecks, images, and restart policy in the repository. This is the deployable unit. |
| Release execution | CI | **Gitea Actions or an equivalent CI runner** | Builds tagged images, pushes them to a registry, runs `ansible-playbook`, then applies a pinned Compose release and checks health. |
Pulumi state is meaningful only with a shared backend—use Pulumi Cloud or a managed/self-hosted state backend, **not** a developer-local `file://` state file. Pulumis state is what enables previews, refreshes, encrypted secret tracking, and drift detection. [Pulumi state and backends](https://www.pulumi.com/docs/iac/concepts/state-and-backends/) · [Pulumi secrets](https://www.pulumi.com/docs/iac/concepts/secrets/)
Ansible is not redundant: it makes the Contabo host reproducible without pretending that SSH command resources are declarative infrastructure. [Ansible basic concepts](https://docs.ansible.com/projects/ansible/latest/getting_started/)
## Delivery workflow
### Local iteration
1. Keep using the current root `pnpm dev:tailscale` stack and personal Convex dev deployment.
2. Use the Macs Tailscale URL for phone testing.
3. Never edit staging Convex variables, staging OAuth callback settings, or staging VDS volumes from the local loop.
4. Merge only when targeted runtime smoke testing and repository checks pass.
### Staging release
1. A merge to the staging branch (initially `master` if that is the stable branch) starts CI.
2. CI runs type checks and targeted tests.
3. CI builds the web for the **staging Convex deployment** and deploys it to the dedicated staging Vercel project. Build-time `VITE_CONVEX_URL` and `VITE_AUTH_URL` must be staging values.
4. CI builds immutable `agents` and `runner` images tagged with commit SHA; it sets `RIVET_RUNNER_VERSION` from the release identity.
5. CI runs Ansible convergence, updates the Compose release to those exact image digests, and executes `docker compose up -d`.
6. CI verifies: Vercel URL returns 200, Convex auth origin is accepted, agents health endpoint returns 200, engine health returns 200 inside the private network, and a real signed-in staging conversation receives an agent response.
7. Rollback means redeploying the previous image digests and restoring the corresponding Vercel deployment—not rebuilding mutable `latest` images.
Do not couple the Vercel deployment to Gitea-native Git integration assumptions. The repository is hosted on Gitea, so start with CI invoking the Vercel CLI/API. If a Git mirror is later introduced, Vercel previews can be enabled separately.
## Required implementation backlog
This research does **not** deploy anything. Before the first staging release, complete these changes in order:
1. Create the separate `staging` Convex deployment and its deploy key; configure deployment-scoped secrets and `SITE_URL`.
2. Correct the auth/webhook origin seams:
- Add Vercel production rewrites for `/api/auth/*` to the staging Convex Site URL; Vites current proxy only applies to local development.
- Make the Puter webhook target the staging Convex Site HTTP action directly, rather than the web origin.
3. Add Vercel React Router support and a dedicated staging deployment configuration.
4. Create the four-service Compose topology and a runner-capable image. The current agents Dockerfile starts only Flue and does not provide the dedicated Bun/Git runner service.
5. Add health routes/checks, resource limits, volume backup, image-digest deployments, and `RIVET_RUNNER_VERSION`.
6. Add `infra/pulumi` and `infra/ansible`, then CI environments with protected staging secrets.
7. Execute an end-to-end staging smoke: sign in, create/connect a project, send a conversation, and complete a disposable issue-to-PR job.
## Security and operations guardrails
- Keep environment secrets in CI environment secret stores, Pulumi encrypted configuration/ESC where appropriate, Convex deployment variables, and Vercel environment variables. Never commit `.env` files or place secrets in image layers.
- Use unique `FLUE_DB_TOKEN`, Rivet admin token, workspace token, model credential, and Git token per environment.
- The VDS runs code-writing agents. Use a dedicated service user; do not mount the host home directory; expose only scoped repository credentials to individual attempts; and keep staging separate from any production host. This follows the repository-isolation policy in `docs/TECH.md` §11.
- Back up Rivet engine state and Caddy configuration. Treat workspaces as reproducible/ephemeral unless an active job requires retention; prune completed worktrees deliberately.
- A single Flue Node instance is the correct initial staging shape. Its durable Convex adapter survives restarts, but each conversation still requires one live owner—do not add replicas until ownership routing is designed. [Flue database guide](https://flueframework.com/docs/guide/database)
## Sources
- Repository architecture: `docs/TECH.md` §§1, 1011; `docs/LOCAL_SETUP.md`; `apps/web/Dockerfile`; `packages/agents/Dockerfile`; `packages/agents/src/runtime/repository-workspace.ts`; `packages/agents/src/runtime/attempt-runner.ts`.
- [Convex environments and deployments](https://docs.convex.dev/production/multiple-deployments), [hosting on Vercel](https://docs.convex.dev/production/hosting/vercel), and [HTTP actions](https://docs.convex.dev/functions/http-actions).
- [Vercel React Router](https://vercel.com/docs/frameworks/frontend/react-router) and [Vercel environments](https://vercel.com/docs/deployments/environments).
- [Rivet self-hosted Docker Compose](https://rivet.dev/docs/self-hosting/docker-compose), [production checklist](https://rivet.dev/docs/self-hosting/production-checklist), and [version upgrades](https://rivet.dev/docs/actors/versions).
- [Flue Node/Docker deployment](https://flueframework.com/docs/ecosystem/deploy/docker) and [durable database ownership](https://flueframework.com/docs/guide/database).
- [Pulumi state](https://www.pulumi.com/docs/iac/concepts/state-and-backends/), [Pulumi secrets](https://www.pulumi.com/docs/iac/concepts/secrets/), and [Ansible](https://docs.ansible.com/projects/ansible/latest/getting_started/).

333
docs/LOCAL_SETUP.md Normal file
View File

@@ -0,0 +1,333 @@
# 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://<tailscale-ip>:3585'
```
Convex runs in the cloud, so `FLUE_URL` must be reachable from the deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue on 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
# Publish once after backend changes
pnpm --filter @code/backend dev:once
# Watch and republish while developing
pnpm dev:server
```
Both commands run from `packages/backend` and explicitly load the repository-root `.env` with `--env-file=../../.env`. `packages/backend/.env.local` remains Convex CLI metadata for the selected deployment; keep the shared runtime settings in the root `.env`.
### 2. Start the whole stack
The root `dev` script starts Rivet Engine, the AgentOS registry runner, Convex, Flue agents, and the web app in one terminal:
```bash
pnpm dev
```
For phone or Tailscale-device access, start the stack with the web server bound to all interfaces:
```bash
pnpm dev:tailscale
```
### 3. Start Rivet Engine
If you prefer separate terminals, start the engine from the agents package:
```bash
cd packages/agents
bun run dev:engine
```
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.
### 4. 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.
### 5. Start Flue agents
For laptop-only access:
```bash
pnpm dev:agents
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
pnpm dev:tailscale:agents
```
The dev scripts already use 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
```
### 6. Start the web application
If you are not using the root `dev` script, start the web app separately.
For laptop-only access:
```bash
pnpm dev:web
```
For phone or other Tailscale-device access:
```bash
pnpm 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

@@ -5,7 +5,7 @@
> **Status:** Working source of truth
> **Audience:** product, design, engineering, agents
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
> **Product boundary:** this file defines *what and why*, not implementation details.
> **Product boundary:** this file defines _what and why_, not implementation details.
## 1. Thesis
@@ -35,7 +35,7 @@ Technical founders and small engineering teams working in one Git repository.
```text
Project chat
→ actionable Signal
approved Work Definition
ready Work Definition
→ lightweight Design Packet
→ vertical slices
→ isolated coding run
@@ -90,7 +90,7 @@ A Work card is the durable, inspectable representation of one desired outcome. I
All decisions requiring a human are first-class objects:
- clarify intent;
- approve definition/design;
- definition/design persist automatically;
- grant permission;
- select an alternative;
- review a slice;
@@ -249,18 +249,18 @@ Canonical knowledge updates require review.
High-quality software is evaluated across:
| Dimension | Required question |
|---|---|
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
| Dimension | Required question |
| ----------------- | -------------------------------------------------------- |
| Correctness | Does behavior satisfy the accepted criteria? |
| Regression safety | Did existing behavior remain valid? |
| Maintainability | Does the change preserve understandable boundaries? |
| Security | Are permissions, secrets, data, and dependencies safe? |
| Reliability | Are failures and edge cases handled? |
| Operability | Can it be deployed, observed, debugged, and rolled back? |
| Performance | Are expected resource limits preserved? |
| UX | Does the actual user path work? |
| Traceability | Can claims be tied to evidence and revisions? |
| Reversibility | Can risk be disabled or rolled back? |
### Completion rule
@@ -283,7 +283,7 @@ The implementation worker MUST NOT be the sole authority on completion.
Builder → candidate output
Verifier → independent evidence
Resolver → completion decision
Human → policy-defined approvals
Human → merge/release authorization
```
### Risk lanes
@@ -309,7 +309,7 @@ definition → combined design → 13 slices → verify → review → PR
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
```text
definition approval → architecture approval → program-design approval
definition → design → ready → merge/release
→ one slice at a time → staged release → observation/rollback gate
```
@@ -356,8 +356,8 @@ Humans should spend attention on high-leverage decisions, not supervise tool cal
Preferred gates:
- Work Definition approval;
- Design Packet approval for standard/critical work;
- Work Definition (no approval gate);
- Design Packet (no approval gate);
- slice review when risk requires it;
- merge/release approval;
- resolution of ambiguity or policy exceptions.

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 |

View File

@@ -198,9 +198,7 @@ All external/model payloads MUST be decoded with schemas at boundaries.
```text
Proposed
→ Defining
→ AwaitingDefinitionApproval
→ Designing
→ AwaitingDesignApproval
→ Ready
→ ExecutingSlice
→ VerifyingSlice
@@ -242,7 +240,7 @@ Initial central aggregate:
- Design Packet and versions;
- slice/step graph;
- Resolver state;
- approvals/questions;
- questions;
- artifact references;
- result.
@@ -425,7 +423,7 @@ HarnessRuntime
v0 selects one harness. Prefer mature coding harnesses for repository exploration/edit/test loops; use custom FLUE agents for product-specific reasoning.
Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completion. Harness owns one bounded implementation loop.
Zopu owns Work, branches, worktrees, budgets, artifacts, and completion. Harness owns one bounded implementation loop.
## 10. Runtime strategy
@@ -580,7 +578,7 @@ Never create a “verified PR” from a different SHA than the verified candidat
Review package:
- original intent;
- approved definition/design;
- current definition/design;
- slice narrative;
- meaningful diffs;
- screenshots/video;
@@ -608,7 +606,7 @@ Production release requires explicit policy, rollout plan, health signals, and r
- no long-lived model/Git secrets in workspace files;
- network egress policy;
- artifact access authorization;
- immutable audit trail for approvals;
- immutable audit trail for all Work events;
- tool allowlist per Kit;
- destructive tools denied by default;
- merge/deploy human-gated initially;
@@ -663,7 +661,7 @@ Keep Cube control APIs private; expose only authorized preview paths. Rivet and
The architecture is proven when one real repository supports:
```text
message → Signal → approved Work → approved Design Packet
message → Signal → proposed Work → ready Work
→ one slice → isolated harness run → independent verification
→ verified commit → real PR → human response/resume
```

View File

@@ -195,9 +195,9 @@ Prefer explicit state machines over booleans.
Bad:
```ts
isRunning: boolean
isDone: boolean
hasError: boolean
isRunning: boolean;
isDone: boolean;
hasError: boolean;
```
Good:
@@ -213,7 +213,7 @@ type AttemptStatus =
| "verification_failed"
| "budget_exhausted"
| "cancelled"
| "permanent_failure"
| "permanent_failure";
```
Transitions must validate current version/state.
@@ -281,38 +281,44 @@ Design replaceable boundaries, then ship one path.
```md
## Implemented
- ...
## Behavior
- ...
## Verification run
- `command` — pass/fail
- ...
## Evidence
- candidate SHA/artifacts
- ...
## Design deviations
- none / ...
## Remaining risks or blockers
- none / ...
```
## 16. Required reading by task type
| Task | Read |
|---|---|
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
| Task | Read |
| ------------------ | ---------------------------------------- |
| product/domain | product.md, glossary.md |
| UI | design.md, product.md |
| actors/state | tech.md, glossary.md |
| orchestration | dev-loop.md, tech.md |
| current sequencing | slices.md, dev-plan.md |
| verification/evals | evaluation.md, dev-loop.md |
| provider adapter | tech.md plus provider docs |
| broad feature | agent-context.md + all relevant sections |
## 17. Stop and escalate when

View File

@@ -1,17 +1,15 @@
# 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.
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 |
| Prefix | Target |
| ------------- | --------------------- |
| `/api/auth/*` | Convex HTTP site |
| `/*` | React Router frontend |
### Caddy
@@ -31,8 +29,7 @@ zopu.example.com {
### Dokploy / Traefik
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
site URL. Ensure it:
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)
@@ -44,8 +41,7 @@ site URL. Ensure it:
## Convex environment
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
site URL:
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex site URL:
```bash
# Production
@@ -58,15 +54,20 @@ npx convex env set SITE_URL 'http://100.101.157.28:5173'
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`).
This value is Better Auth's public `baseURL` and `trustedOrigins`. It must be the browser origin because Better Auth writes OAuth state cookies and receives provider callbacks through the same-origin proxy. `CONVEX_SITE_URL` remains the internal Convex HTTP site URL.
## One origin per shared OAuth deployment
GitHub OAuth Apps expose one authorization callback URL. The value must exactly equal `<SITE_URL>/api/auth/callback/github`, including scheme, host, port, and path. A GitHub authorization request whose `redirect_uri` differs returns `Invalid Redirect URI` before the user can grant access.
`SITE_URL` is also single-valued in a Convex deployment. Therefore, two browser origins that share one deployment—such as Tailscale development and Puter staging—cannot use GitHub OAuth at the same time. Either switch both the Convex `SITE_URL` and the GitHub OAuth App callback together before testing the other origin, or give each public environment its own Convex deployment and OAuth App.
Never set Better Auth's `baseURL` to `CONVEX_SITE_URL` for the browser flow. The browser creates the OAuth state cookie on `SITE_URL`; a provider callback sent directly to `CONVEX_SITE_URL` cannot read that cookie and fails with `state_mismatch`.
## 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.
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.**

View File

@@ -1,11 +1,10 @@
# Deployment Notes
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`).
Each runs its own web server, Flue agents process, and `.env` file.
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`). Each runs its own web server, Flue agents process, and `.env` file.
## Environments
| | Local Mac Dev | Cheaptricks Staging |
| | Local Mac Dev | Cheaptricks Staging |
| --- | --- | --- |
| SSH alias | (local) | `cheaptricks` |
| Repo path | `/Users/puter/Workspace/zopu/code` | `/workspace/code` |
@@ -19,12 +18,9 @@ Each runs its own web server, Flue agents process, and `.env` file.
## Shared Convex deployment
Deployment name: `dev:befitting-dalmatian-161`
Convex URL: `https://befitting-dalmatian-161.convex.cloud`
Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Deployment name: `dev:befitting-dalmatian-161` Convex URL: `https://befitting-dalmatian-161.convex.cloud` Convex Site URL: `https://befitting-dalmatian-161.convex.site`
Convex env vars are deployment-scoped, not per-machine. Authenticate from any
machine with `npx convex dev` inside `packages/backend`.
Convex env vars are deployment-scoped, not per-machine. Authenticate from any machine with `npx convex dev` inside `packages/backend`.
Key Convex env var:
@@ -32,9 +28,7 @@ Key Convex env var:
SITE_URL = <the origin Better Auth should trust>
```
This controls `trustedOrigins` in the Better Auth config
(`packages/backend/convex/auth.ts`). It must match the URL the browser
actually visits, or sign-in fails silently with a CORS rejection.
This controls `trustedOrigins` in the Better Auth config (`packages/backend/convex/auth.ts`). It must match the URL the browser actually visits, or sign-in fails silently with a CORS rejection.
When switching between environments:
@@ -48,6 +42,10 @@ npx convex env set SITE_URL 'http://100.101.157.28:5173'
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
### OAuth consequence
`SITE_URL` drives both Better Auth's public callback origin and the single GitHub OAuth App callback. When switching the shared deployment between the local and staging origins, also update the GitHub OAuth App's **Authorization callback URL** to `<SITE_URL>/api/auth/callback/github` before attempting a GitHub connection. GitHub OAuth Apps allow one callback URL; use distinct Convex deployments and OAuth Apps if both environments must operate concurrently.
## Local Mac Dev `.env`
```env
@@ -143,16 +141,24 @@ zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
# Must precede the generic /api route: preserves /api/auth/* and cookies.
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
}
}
handle_path /api/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
handle {
reverse_proxy 127.0.0.1:5173
}
}
```
`/api/*` routes to the Flue agents process (port 3585).
Everything else routes to the web dev server (port 5173).
`/api/auth/*` is the same-origin Better Auth proxy to Convex. The generic `/api/*` route serves Flue at port `3585`; it must be evaluated only after the authentication route.
Reload after changes:
@@ -162,8 +168,7 @@ sudo systemctl reload caddy
### Vite allowedHosts
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or
Vite rejects requests arriving through the Caddy domain.
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or Vite rejects requests arriving through the Caddy domain.
### Start staging dev
@@ -211,23 +216,18 @@ Both environments use the same model via the Cheaptricks AI gateway:
- Multimodal: text + image input
The `AGENT_MODEL_BASE_URL` differs:
- Local Mac: uses the external gateway URL
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
## Known gotchas
1. **Convex SITE_URL is single-valued.** Only one origin can be trusted at a
time. Switch it when moving between local and staging, or sign-in breaks
silently with a CORS error in the browser console.
1. **Convex `SITE_URL` is single-valued and is the OAuth callback origin.** Switch it together with the GitHub OAuth App's single callback URL when moving between local and staging. Running both origins concurrently requires separate Convex deployments and OAuth Apps.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed
via `server.allowedHosts` in `apps/web/vite.config.ts`.
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed via `server.allowedHosts` in `apps/web/vite.config.ts`.
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at
3583/13100. Current ports are 3585 (Flue) and 5173 (web).
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at 3583/13100. Current ports are 3585 (Flue) and 5173 (web).
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo
ships `.env.example` as the template.
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo ships `.env.example` as the template.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
`packages/backend` on each new machine to authenticate the CLI.
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside `packages/backend` on each new machine to authenticate the CLI.

View File

@@ -644,7 +644,7 @@ Answers are durable Decisions and resume the same Work.
## 17. Resolver decision table
| Condition | Resolver action |
|---|---|
| --- | --- |
| executable slice exists | start bounded Attempt |
| transient infrastructure/model failure | retry by policy |
| candidate fails checks | create repair Attempt with evidence |

View File

@@ -6,25 +6,25 @@ You have shell access and may use the Paseo CLI to create and manage other Codex
All child agents must use:
--provider codex/glm-5.2
--provider codex/glm-5.2
The current repository is the Zopu monorepo. It already contains product documentation, design documentation, technical documentation, Effect primitives, Convex backend code, web/mobile/desktop applications, agent code, project-management primitives, Signals, and partial AgentOS/Rivet integration.
The starting branch is expected to be:
feat/projects-backend
feat/projects-backend
Verify this rather than blindly assuming it.
The final integration branch should be:
dogfood/v0
dogfood/v0
Your job is to manage the entire implementation through parallel isolated worktrees.
======================================================================
1. PRODUCT OBJECTIVE
======================================================================
1. PRODUCT OBJECTIVE ======================================================================
Ship the smallest complete Zopu dogfooding loop for the Zopu repository itself:
@@ -67,25 +67,11 @@ Ship the smallest complete Zopu dogfooding loop for the Zopu repository itself:
The completed proof should be something like:
User:
“Add a health endpoint that exposes the current build commit.”
User: “Add a health endpoint that exposes the current build commit.”
Result:
✓ exact message evidence stored
✓ Signal created
✓ Work Unit created or updated
✓ work started
✓ Orb provisioned
✓ OpenCode session started
✓ repository changed
✓ tests executed
✓ branch committed and pushed
✓ pull request opened
✓ work card updated
✓ contextual follow-up reaches the active work
Result: ✓ exact message evidence stored ✓ Signal created ✓ Work Unit created or updated ✓ work started ✓ Orb provisioned ✓ OpenCode session started ✓ repository changed ✓ tests executed ✓ branch committed and pushed ✓ pull request opened ✓ work card updated ✓ contextual follow-up reaches the active work
======================================================================
2. HARD SCOPE
====================================================================== 2. HARD SCOPE
======================================================================
This is a narrow dogfooding implementation.
@@ -128,6 +114,7 @@ ProjectIssue may remain the backend representation of a Work Unit for this versi
Project events may represent execution steps and activity.
Project artifacts may represent:
- work.md;
- steps.md;
- agent reports;
@@ -136,8 +123,7 @@ Project artifacts may represent:
- commits;
- pull requests.
======================================================================
3. OPERATING RULES
====================================================================== 3. OPERATING RULES
======================================================================
Before changing anything:
@@ -197,22 +183,18 @@ When an agent is blocked:
Do not stop after spawning agents. You must monitor and integrate them.
======================================================================
4. INITIAL BRANCH PREPARATION
====================================================================== 4. INITIAL BRANCH PREPARATION
======================================================================
Inspect the current branch.
If currently on feat/projects-backend:
git pull --ff-only
git switch -c dogfood/v0
git push -u origin dogfood/v0
git pull --ff-only git switch -c dogfood/v0 git push -u origin dogfood/v0
If dogfood/v0 already exists:
git switch dogfood/v0
git pull --ff-only
git switch dogfood/v0 git pull --ff-only
If the expected branch is different, inspect the repository history and choose the branch containing the current projects backend work. Do not discard existing uncommitted work.
@@ -220,23 +202,20 @@ Run the baseline checks before spawning agents. Record failures but do not spend
Suggested baseline:
bun install
bun run check
bun install bun run check
Also inspect package-specific scripts and use the actual commands defined by the repository.
======================================================================
5. PASEO ORCHESTRATION
====================================================================== 5. PASEO ORCHESTRATION
======================================================================
Verify Paseo first:
paseo status
paseo ls
paseo status paseo ls
If the daemon is unavailable, inspect:
tail -n 200 ~/.paseo/daemon.log
tail -n 200 ~/.paseo/daemon.log
Do not continue spawning until Paseo works.
@@ -245,24 +224,24 @@ For every child lane:
1. Create a worktree workspace:
paseo workspace create \
--isolation worktree \
--mode branch-off \
--new-branch <branch> \
--base dogfood/v0 \
--title "<title>" \
--json
--isolation worktree \
--mode branch-off \
--new-branch <branch> \
--base dogfood/v0 \
--title "<title>" \
--json
2. Read the returned workspaceId.
3. Start the child agent:
paseo run \
--provider codex/glm-5.2 \
--workspace <workspaceId> \
--title "<title>" \
--background \
--json \
"<full lane prompt>"
--provider codex/glm-5.2 \
--workspace <workspaceId> \
--title "<title>" \
--background \
--json \
"<full lane prompt>"
4. Save:
- branch;
@@ -273,33 +252,27 @@ For every child lane:
Maintain an orchestration note in a temporary file outside committed product code, for example:
/tmp/zopu-dogfood-orchestration.md
/tmp/zopu-dogfood-orchestration.md
Track:
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
|------|--------|--------------|----------|--------|----|
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
| ---- | ------ | ------------ | -------- | ------ | --- |
Use:
paseo ls
paseo inspect <agentId>
paseo logs <agentId>
paseo send <agentId> "<focused instruction>"
paseo wait <agentId>
paseo ls paseo inspect <agentId> paseo logs <agentId> paseo send <agentId> "<focused instruction>" paseo wait <agentId>
Do not block waiting on one child while others are still running. Poll all first-wave agents periodically.
======================================================================
6. SHARED CHILD-AGENT PREFIX
====================================================================== 6. SHARED CHILD-AGENT PREFIX
======================================================================
Prepend the following instructions to every child prompt:
You are implementing one isolated lane of the Zopu dogfood v0 loop.
Repository base branch:
dogfood/v0
Repository base branch: dogfood/v0
Your branch is already isolated in a Paseo Git worktree.
@@ -347,29 +320,30 @@ Before finishing:
- PR URL or exact PR creation instructions;
- remaining limitations.
======================================================================
7. FIRST-WAVE PARALLEL LANES
====================================================================== 7. FIRST-WAVE PARALLEL LANES
======================================================================
Spawn all four first-wave agents immediately.
----------------------------------------------------------------------
---
LANE A — PROJECT CHAT, SIGNALS AND WORK ROUTING
----------------------------------------------------------------------
Branch:
dogfood/zopu-orchestrator
dogfood/zopu-orchestrator
Title:
Zopu Signals Orchestrator
Zopu Signals Orchestrator
Prompt:
Implement the project-scoped Zopu chat, Signal extraction and work-routing loop.
Inspect the existing:
- Zopu agent;
- Signals primitive;
- ProjectIssue primitive;
@@ -421,10 +395,10 @@ Rules:
Acceptance examples:
Message:
“The work-unit card should expose the generated PR.”
Message: “The work-unit card should expose the generated PR.”
Expected:
- exact message persisted;
- Signal created;
- attached to an existing UI/workspace issue if relevant;
@@ -449,17 +423,18 @@ Likely ownership:
- packages/backend/convex/schema*
- Signal/project-issue primitives only as required.
----------------------------------------------------------------------
---
LANE B — ORB RUNTIME FOUNDATION
----------------------------------------------------------------------
Branch:
dogfood/orb-runtime
dogfood/orb-runtime
Title:
AgentOS OpenCode Orb Runtime
AgentOS OpenCode Orb Runtime
Prompt:
@@ -525,6 +500,7 @@ Keep product-domain concepts separate from infrastructure leases.
Do not make AgentOS VM state equal the work-unit state.
Actor identity must include:
- project;
- issue/work unit;
- run.
@@ -577,21 +553,23 @@ Document:
- current limitations.
Do not modify:
- Zopu chat behavior;
- project-manager delegation;
- web UI.
----------------------------------------------------------------------
---
LANE C — WEB WORKSPACE AND WORK CARDS
----------------------------------------------------------------------
Branch:
dogfood/web-workspace
dogfood/web-workspace
Title:
Zopu Web Work OS
Zopu Web Work OS
Prompt:
@@ -613,18 +591,21 @@ Product projection:
Required interface:
Header:
- Zopu;
- current project;
- project/runtime connection status;
- basic project switcher if already supported.
Main surface:
- global project conversation;
- active Work Unit cards;
- lightweight Signals display;
- persistent composer.
Collapsed Work Unit card:
- title;
- latest summary;
- linked Signal count;
@@ -635,6 +616,7 @@ Collapsed Work Unit card:
- needs-input indicator when available.
Expanded Work Unit:
- objective;
- linked Signals and provenance;
- current plan or steps;
@@ -705,17 +687,18 @@ Tests:
- empty state;
- needs-input display.
----------------------------------------------------------------------
---
LANE D — SINGLE-NODE EXECUTION DEPLOYMENT
----------------------------------------------------------------------
Branch:
dogfood/runtime-deploy
dogfood/runtime-deploy
Title:
Zopu Dedicated Runtime Deployment
Zopu Dedicated Runtime Deployment
Prompt:
@@ -794,8 +777,7 @@ The deployment must be reproducible on a clean Debian host.
Do not require the developers MacBook to remain online after deployment.
======================================================================
8. FIRST-WAVE MONITORING
====================================================================== 8. FIRST-WAVE MONITORING
======================================================================
After spawning all four first-wave agents:
@@ -815,16 +797,15 @@ Use concise interventions.
Examples:
paseo send <agentId> "Keep ProjectIssue as the v0 Work Unit. Do not introduce a second generic work-unit schema."
paseo send <agentId> "Keep ProjectIssue as the v0 Work Unit. Do not introduce a second generic work-unit schema."
paseo send <agentId> "Do not wire project-manager yet. Finish the Orb port, adapter and proof fixture only."
paseo send <agentId> "Do not wire project-manager yet. Finish the Orb port, adapter and proof fixture only."
paseo send <agentId> "Use existing Convex contracts. Avoid backend schema changes in the UI lane."
paseo send <agentId> "Use existing Convex contracts. Avoid backend schema changes in the UI lane."
Wait until each first-wave agent is idle, then inspect:
paseo inspect <agentId>
paseo logs <agentId>
paseo inspect <agentId> paseo logs <agentId>
Review each branch yourself.
@@ -842,8 +823,7 @@ For every lane:
Do not merge a failing lane merely because the child says it works.
======================================================================
9. FIRST-WAVE MERGE PROCESS
====================================================================== 9. FIRST-WAVE MERGE PROCESS
======================================================================
Recommended merge order:
@@ -855,11 +835,7 @@ Recommended merge order:
Before every merge:
git switch dogfood/v0
git pull --ff-only
git fetch origin
git log --oneline --decorate --max-count=10 origin/<branch>
git diff dogfood/v0...origin/<branch>
git switch dogfood/v0 git pull --ff-only git fetch origin git log --oneline --decorate --max-count=10 origin/<branch> git diff dogfood/v0...origin/<branch>
Run lane-specific checks.
@@ -867,31 +843,28 @@ Merge using the repositorys normal PR workflow when possible.
If merging locally:
git merge --no-ff origin/<branch>
git merge --no-ff origin/<branch>
Resolve conflicts conservatively.
After each merge:
bun install
run targeted checks
git push origin dogfood/v0
bun install run targeted checks git push origin dogfood/v0
Do not squash away useful commits unless repository policy requires squashing.
======================================================================
10. SECOND-WAVE LANE — WIRE PROJECT MANAGER TO ORBS
====================================================================== 10. SECOND-WAVE LANE — WIRE PROJECT MANAGER TO ORBS
======================================================================
Only spawn this lane after the Orb runtime branch is integrated into dogfood/v0.
Branch:
dogfood/orb-wiring
dogfood/orb-wiring
Title:
Project Manager Orb Wiring
Project Manager Orb Wiring
Create its Paseo workspace from the updated dogfood/v0 branch.
@@ -944,20 +917,13 @@ Refactor the current Git lifecycle so it can be invoked from the Orb/application
Failure mapping:
- runtime unavailable:
infrastructure failure with clear reason;
- missing user context:
needs input;
- failed tests that can be repaired:
agent continues;
- unrecoverable repeated failure:
failed with logs and evidence;
- cancellation:
terminate OpenCode and sandbox processes;
- Git rejection:
preserve commit and expose exact failure;
- PR creation failure:
retain pushed branch and retry safely.
- runtime unavailable: infrastructure failure with clear reason;
- missing user context: needs input;
- failed tests that can be repaired: agent continues;
- unrecoverable repeated failure: failed with logs and evidence;
- cancellation: terminate OpenCode and sandbox processes;
- Git rejection: preserve commit and expose exact failure;
- PR creation failure: retain pushed branch and retry safely.
Idempotency:
@@ -981,8 +947,7 @@ Tests:
Do not add multi-agent parallel execution.
======================================================================
11. SECOND-WAVE REVIEW AND MERGE
====================================================================== 11. SECOND-WAVE REVIEW AND MERGE
======================================================================
Monitor the orb-wiring agent closely.
@@ -1007,19 +972,18 @@ Review and merge it into dogfood/v0 only after:
- idempotency is demonstrated;
- no secrets appear in logs or artifacts.
======================================================================
12. FINAL LANE — END-TO-END DOGFOOD INTEGRATION
====================================================================== 12. FINAL LANE — END-TO-END DOGFOOD INTEGRATION
======================================================================
After all implementation lanes are merged, spawn the final integration agent.
Branch:
dogfood/e2e-integration
dogfood/e2e-integration
Title:
Zopu Dogfood End-to-End
Zopu Dogfood End-to-End
Prompt:
@@ -1100,7 +1064,7 @@ Verification matrix:
Produce:
docs/DOGFOOD_V0.md
docs/DOGFOOD_V0.md
It must contain:
@@ -1119,8 +1083,7 @@ It must contain:
Commit, push and open a PR targeting dogfood/v0.
======================================================================
13. FINAL INTEGRATION RESPONSIBILITIES
====================================================================== 13. FINAL INTEGRATION RESPONSIBILITIES
======================================================================
After the final agent completes:
@@ -1146,8 +1109,7 @@ If the full live path cannot complete:
The final system must not claim that a PR exists unless it exists in Git.
======================================================================
14. QUALITY GATES
====================================================================== 14. QUALITY GATES
======================================================================
A lane is not done merely because code was generated.
@@ -1184,8 +1146,7 @@ The complete v0 must satisfy:
- PR is created;
- PR is visible in the UI.
======================================================================
15. RESOURCE AND CONCURRENCY LIMITS
====================================================================== 15. RESOURCE AND CONCURRENCY LIMITS
======================================================================
For this v0:
@@ -1201,12 +1162,11 @@ For this v0:
When agents are no longer needed:
paseo archive <agentId>
paseo archive <agentId>
Do not archive them until their logs and work have been reviewed.
======================================================================
16. COMMUNICATION WITH THE USER
====================================================================== 16. COMMUNICATION WITH THE USER
======================================================================
Do not repeatedly ask the user to choose between reasonable implementation details.
@@ -1230,8 +1190,7 @@ When requesting input, provide:
Continue supervising all unblocked lanes while waiting.
======================================================================
17. FINAL REPORT
====================================================================== 17. FINAL REPORT
======================================================================
When finished, provide a concise but complete report:
@@ -1254,6 +1213,7 @@ When finished, provide a concise but complete report:
## Integrated branches
For each branch:
- purpose;
- final commit;
- PR;

View File

@@ -157,12 +157,12 @@ Observe real Work with human decisions, merge outcomes, incidents, and post-rele
Score each candidate run 03 per dimension.
| Score | Meaning |
|---|---|
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
| Score | Meaning |
| ----- | --------------------------------- |
| 0 | unsafe/incorrect/missing |
| 1 | substantial human repair required |
| 2 | acceptable with minor correction |
| 3 | correct, complete, reviewable |
Dimensions:

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

@@ -0,0 +1,147 @@
# 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** and open the app whose client ID will be stored in Convex.
2. Set the application name and homepage URL. The homepage URL should be the current `SITE_URL`.
3. Set the single **Authorization callback URL** to exactly:
```text
<SITE_URL>/api/auth/callback/github
```
GitHub OAuth Apps support one callback URL. Scheme, host, port, and path must match the `redirect_uri` sent by Better Auth exactly.
4. Generate a client secret.
5. Set the credentials on the Convex deployment—not only in the local `.env` file:
```bash
cd packages/backend
npx convex env set GITHUB_CLIENT_ID <your-client-id>
npx convex env set GITHUB_CLIENT_SECRET <your-client-secret>
npx convex env list
```
The configured `SITE_URL`, GitHub callback URL, and browser URL are one unit. For a shared Convex deployment, switch all three before testing a different public origin. See [Auth Proxy](./auth-proxy.md#one-origin-per-shared-oauth-deployment) for the deployment boundary.
### Requested scopes
Zopu requests GitHub identity and private-repository access: `read:user`, `user:email`, `repo`, and `read:org`. `repo` grants access to private repositories; `read:org` permits organization repository discovery.
## 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`.
### OAuth diagnostics
| Symptom | Cause | Fix |
| --- | --- | --- |
| `Provider not found` from `/api/auth/link-social` | `GITHUB_CLIENT_ID` or `GITHUB_CLIENT_SECRET` is absent from the Convex deployment env store. A local `.env` does not configure cloud functions. | Set both variables with `npx convex env set` and verify with `npx convex env list`. |
| GitHub `Invalid Redirect URI` | GitHub's saved callback does not exactly equal the app's `redirect_uri`. | Set the OAuth App's single callback to `<SITE_URL>/api/auth/callback/github`. |
| `state_mismatch` at a `convex.site` page | Better Auth started on the public origin but GitHub returned to the direct Convex host, so the public-origin state cookie is unavailable. | Set Better Auth `baseURL` to `SITE_URL`; proxy `/api/auth/*` through the public origin. |
| `email_doesn't_match` after selecting GitHub | A deployment predates the explicit-link policy or has not received the current auth configuration. | Deploy the current backend. Explicit `linkSocial` allows a different GitHub email without changing the Zopu account email. |
| `externalEmail` is `null` during `connectGithub` | The GitHub account hides its public email; GitHub returns `null` from `/user`. | Deploy the current backend, which normalizes nullable provider emails before Convex persistence. |
GitHub connections are explicit authenticated account links. The linked GitHub email may differ from the existing Zopu account email; Zopu retains the existing account email and only stores the GitHub identity and OAuth credential needed for repository access.
### 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

@@ -3,7 +3,7 @@
> **Purpose:** prevent agents and humans from using overlapping terms inconsistently.
| Term | Definition | Not the same as |
|---|---|---|
| --- | --- | --- |
| Project | Durable product/repository/context boundary. | Work or chat thread |
| Message | Exact conversational input/output. | Signal |
| Signal | Provenanced evidence that may require attention. | Work |

View File

@@ -1,12 +1,11 @@
# Slice 1 Handoff Notes
Date: 2026-07-27
Branch: `master` at `cfdb2efc7`
PR: #19 (merged)
Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
## What was built
### Core product loop
- Conversation to Signal to proposed Work with exact source provenance.
- Convex owns durable data: `conversationMessages`, `signals`, `works`, `workEvents`.
- Effect validation in `@code/work-os` package.
@@ -15,6 +14,7 @@ PR: #19 (merged)
- Mobile-first UI at `apps/web/src/components/slice-one/slice-one-page.tsx`.
### Model
- MiMo V2.5 (`xiaomi/mimo-v2.5`) via Cheaptricks AI gateway.
- Provider identity `xiaomi` gives Flue correct multimodal metadata (text + image).
- `AGENT_MODEL_BASE_URL` points at Cheaptricks; API key in `.env`.
@@ -22,28 +22,33 @@ PR: #19 (merged)
- `thinkingLevel: "medium"`.
### Reasoning traces
- Native Flue `reasoning` parts render as live "Thinking trace" (open while streaming, collapsed after).
- Inline `<think>...</think>` extraction for models that embed thinking in text.
- No fallback that hides model output.
### Image attachments
- Picker button + hidden file input, up to 4 images at 10 MB each.
- Base64 conversion into Flue `AgentPromptImage`.
- Authenticated blob-URL rendering for historical image replay.
- Verified live: MiMo read exact text and background color from generated test images.
### Mobile keyboard fix
- Visual viewport hook (`use-visual-viewport.ts`).
- Fixed full-screen shell; `interactive-widget=resizes-content` in viewport meta.
- Header stays pinned; chat shrinks; composer follows keyboard.
- Regression tests in `frontend-regressions.test.ts` and `use-visual-viewport.test.ts`.
### Rendering
- Streamdown markdown streaming with Mermaid chart support.
- Tool turns hidden; reasoning-containing tool turns remain visible.
- Work cards render inline in the conversation timeline with source navigation.
### Deployment
- Cheaptricks staging live at `https://zopu.cheaptricks.puter.wtf`.
- Caddy reverse proxy: `/api/*` to Flue (port 3585), root to web (port 5173).
- `server.allowedHosts: true` in `apps/web/vite.config.ts` for Caddy domain.
@@ -52,6 +57,7 @@ PR: #19 (merged)
- Deployment notes at `docs/deployment.md`.
### Repository cleanup
- 17 stale worktrees removed (Codex, Paseo, T3).
- 23 stale local branches deleted.
- 18 stale remote branches deleted.
@@ -62,16 +68,19 @@ PR: #19 (merged)
## Current state
### Running
- Cheaptricks: web on `:5173`, Flue on `:3585`, both persistent via `nohup`.
- Local Mac: servers stopped. Run `bun run dev:tailscale:agents -- --port 3585` and `bun run dev:tailscale:web` to start.
### Git
- `master` at `cfdb2efc7` on local, Cheaptricks, and remote.
- Only `master` branch locally. Two remote branches remain: `origin/sai/changes`, `origin/t3code/explore-primitives-package` (not cleaned because they may be owned by other contributors/tools).
## Known issues and next steps
1. **Convex SITE_URL is single-valued.** Switch it when moving between local and staging:
```bash
cd packages/backend
npx convex env set SITE_URL 'http://100.101.157.28:5173' # local

View File

@@ -3,4 +3,10 @@ import ultracite from "ultracite/oxfmt";
export default defineConfig({
...ultracite,
ignorePatterns: [
...ultracite.ignorePatterns,
"**/.agents/skills/**",
"repos/**",
"scripts/**",
],
});

View File

@@ -5,27 +5,43 @@ import remix from "ultracite/oxlint/remix";
export default defineConfig({
extends: [core, react, remix],
ignorePatterns: [...core.ignorePatterns, "repos/**", "scripts/**"],
ignorePatterns: [
...core.ignorePatterns,
"**/.agents/skills/**",
"repos/**",
"scripts/**",
],
overrides: [
{
files: ["packages/ui/src/components/*.tsx"],
rules: {
"eslint/func-style": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"sort-keys": "off",
},
},
{
files: ["**/convex/**/*.ts", "**/convex/**/*.test.ts"],
rules: {
"unicorn/filename-case": "off",
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
"unicorn/no-array-sort": "off",
"no-await-in-loop": "off",
// Convex query builders use `any` in index callbacks
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"eslint/complexity": "off",
"no-await-in-loop": "off",
"unicorn/filename-case": "off",
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
"unicorn/no-array-sort": "off",
},
},
{
files: ["**/convex/**/*.test.ts"],
rules: {
"unicorn/no-await-expression-member": "off",
"eslint/require-await": "off",
"sort-keys": "off",
"unicorn/no-await-expression-member": "off",
},
},
],

View File

@@ -1,47 +1,6 @@
{
"name": "code",
"private": true,
"type": "module",
"scripts": {
"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/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",
"hooks:setup": "vp config",
"dev:web": "vp run --filter web dev",
"dev:agents": "vp run --filter @code/agents dev",
"dev:tailscale:web": "vp run --filter web dev:tailscale",
"dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
"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": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
"fix": "ultracite fix"
},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "1.0.0-beta.9",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
"vite-plus": "0.2.2",
"vitest": "catalog:"
},
"packageManager": "pnpm@11.17.0",
"workspaces": {
"packages": [
"apps/web",
@@ -56,6 +15,7 @@
"catalog": {
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"rivetkit": "2.3.10",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "17.4.2",
"zod": "4.4.3",
@@ -89,9 +49,58 @@
"@tailwindcss/vite": "4.3.3"
}
},
"type": "module",
"scripts": {
"dev": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:web\"",
"dev:tailscale": "concurrently --names engine,runner,server,agents,web --prefix-colors yellow,blue,magenta,green,cyan --handle-input --kill-others-on-fail \"pnpm run dev:engine\" \"pnpm run dev:runner\" \"pnpm run dev:server\" \"pnpm run dev:agents\" \"pnpm run dev:tailscale:web\"",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"lint": "oxlint --disable-nested-config",
"format": "vp fmt",
"staged": "vp staged",
"hooks:setup": "vp config",
"dev:web": "vp --filter web run dev",
"dev:agents": "vp --filter @code/agents run dev",
"dev:tailscale:web": "vp --filter web run dev:tailscale",
"dev:tailscale:agents": "vp --filter @code/agents run dev:tailscale",
"dev:server": "vp --filter @code/backend run dev",
"dev:setup": "vp --filter @code/backend run dev:setup",
"dev:engine": "vp --filter @code/agents run dev:engine",
"dev:runner": "node scripts/wait-for-port.mjs 127.0.0.1 6420 && vp --filter @code/agents run runner",
"build:agents": "vp run --filter @code/agents build",
"docs:update": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
"vercel-build": "node scripts/vercel-build.ts",
"check": "ultracite check --disable-nested-config",
"fix": "ultracite fix --disable-nested-config"
},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/sdk": "npm:@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@vercel/config": "^0.5.5",
"concurrently": "^9.1.2",
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"react-router": "^8.1.0",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
"vite-plus": "0.2.2",
"vitest": "catalog:"
},
"overrides": {
"react": "19.2.8",
"react-dom": "19.2.8",
"rivetkit": "2.3.10",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
}
},
"packageManager": "pnpm@11.17.0"
}

View File

@@ -13,20 +13,36 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
&& rm -rf /var/lib/apt/lists/*
# pnpm-lock.yaml is authoritative; Bun would migrate it and reject frozen mode.
RUN npm install --global pnpm@11.17.0
COPY . .
RUN bun install --frozen-lockfile
RUN pnpm 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
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
# Attempt preparation occurs in the Flue handler: it clones repositories and
# installs their dependencies before the runner mounts the shared checkout.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 zopu \
&& useradd --system --uid 10001 --gid zopu --home-dir /nonexistent zopu \
&& mkdir -p /var/lib/zopu/workspaces \
&& chown -R zopu:zopu /app /var/lib/zopu
USER zopu
EXPOSE 3000
CMD ["node", "packages/agents/dist/server.mjs"]

View File

@@ -0,0 +1,84 @@
# syntax=docker/dockerfile:1.7
#
# AgentOS runner image for the VDS Compose topology.
#
# This image is distinct from the production Flue image (Dockerfile): it is the
# dedicated runner that registers with the Rivet Engine and owns the persistent
# host filesystem used to clone repositories, install dependencies, and mount
# isolated checkouts into AgentOS workspaces. It needs Bun + Git + the engine-cli
# used by local development, plus Node (required by the Flue/AgentOS runtime).
#
# The runner is internal-only. It never publishes a port; only the engine and
# Compose healthcheck reach it. Secrets are injected at runtime via the Compose
# environment, never baked into a layer.
#
# RIVET_RUNNER_VERSION is a build-time contract: it lets the engine route new
# actors to the new runner and drain old ones. CI sets it to the release
# identity (commit SHA or build timestamp) for every immutable image it pushes.
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim AS build
# Bun is needed to install dependency trees in the cloned workspaces.
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
# Native build toolchain for optional native deps during install.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
g++ \
make \
python3 \
&& rm -rf /var/lib/apt/lists/*
# pnpm-lock.yaml is authoritative; Bun would migrate it and reject frozen mode.
RUN npm install --global pnpm@11.17.0
COPY . .
RUN pnpm install --frozen-lockfile
# Runner image only. No Flue server build is needed: the runner entry point
# (src/runner.ts) calls runtimeRegistry.startAndWait() to register with the
# engine. Building the Flue Node server here would be dead weight.
FROM node:24-bookworm-slim AS runner
# Git and CA certs are required by RepositoryWorkspace to clone user repos and
# install dependencies inside the mounted source mirror.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& git --version
# Carry Bun into the runtime image so `bun install --frozen-lockfile` works on
# cloned checkouts and BUN_EXECUTABLE resolves to a known path.
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
ARG RIVET_RUNNER_VERSION
ENV RIVET_RUNNER_VERSION=${RIVET_RUNNER_VERSION}
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app /app
# Non-root runtime. Compose mounts the workspace bind directory initialized for
# this UID so the process can serve its isolated worktrees.
RUN groupadd --system --gid 10001 zopu \
&& useradd --system --uid 10001 --gid zopu --create-home --home-dir /home/zopu zopu \
&& mkdir -p /var/lib/zopu/workspaces \
&& chown -R zopu:zopu /var/lib/zopu /home/zopu
# Point the runtime at the Bun executable discovered by RepositoryWorkspace.
# /home/zopu is the pi agent home mount target used by attempt-runner.ts.
ENV BUN_EXECUTABLE=/usr/local/bin/bun \
AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
USER zopu
# runner.ts is TypeScript and this repository executes it with Bun locally.
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -3,3 +3,11 @@ import { defineConfig } from "@flue/cli/config";
export default defineConfig({
target: "node",
});
// The Node deploy artifact must not resolve workspace packages to TypeScript
// source at runtime; bundle them with the Flue server instead.
export const vite = {
ssr: {
noExternal: [/^@code\/(?:env|primitives)(?:\/.*)?$/u],
},
};

View File

@@ -7,29 +7,27 @@
"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": "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",
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev --port 3585",
"dev:engine": "node --env-file=../../.env scripts/start-engine.mjs",
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "node --env-file=../../.env src/runner.ts",
"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"
"runner": "bun --env-file=../../.env src/runner.ts",
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu"
},
"dependencies": {
"@agentos-software/git": "0.3.3",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"@flue/runtime": "npm:@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2",
"@rivet-dev/agentos": "catalog:",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"hono": "catalog:",
"rivetkit": "2.3.9",
"valibot": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@flue/cli": "npm:@rivet-dev/labs-flue-cli@1.0.0-beta.9-rivet.2",
"@rivetkit/engine-cli": "2.3.10",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"

View File

@@ -0,0 +1,13 @@
import { spawn } from "node:child_process";
import { getEnginePath } from "@rivetkit/engine-cli";
const enginePath = getEnginePath();
const proc = spawn(enginePath, ["start"], { stdio: "inherit" });
process.on("SIGINT", () => proc.kill("SIGINT"));
process.on("SIGTERM", () => proc.kill("SIGTERM"));
proc.on("exit", (code) => {
process.exit(code ?? 0);
});

View File

@@ -0,0 +1,18 @@
import { parseAgentEnv } from "@code/env/agent";
import { ConvexHttpClient } from "convex/browser";
export interface ConvexServiceClient {
readonly client: ConvexHttpClient;
readonly token: string;
}
/** Shared factory for service-authenticated ConvexHttpClient used by agent tools. */
export const createConvexServiceClient = (
runtimeEnv: Record<string, string | undefined>
): ConvexServiceClient => {
const env = parseAgentEnv(runtimeEnv);
return {
client: new ConvexHttpClient(env.CONVEX_URL),
token: env.FLUE_DB_TOKEN,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import { createWorkPlannerTools } from "../tools/work-planner";
const INSTRUCTIONS = `You are Zopu's private Work Planner.
You may only submit typed proposals through tools:
- a Work Definition proposal;
- a Design Packet proposal containing 1-4 observable, independently verifiable slices;
- structured questions.
You must never approve a Definition or Design, change Work status, start execution, claim implementation, create Git changes, or claim that simulation implemented the Work. Convex validates and stores every proposal. Ask a focused structured question when a high-impact decision is unresolved.`;
export {
convexAgentRoute as attachments,
convexAgentRoute as route,
} from "../auth";
export default defineAgent(({ env }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
description:
"Proposes Work Definitions, Design Packets, and structured questions.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
tools: createWorkPlannerTools(env),
};
});

View File

@@ -1,30 +1,26 @@
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 { createSliceOneTools } from "../tools/slice-one";
import { createSignalRoutingTools } from "../tools/signal-routing";
import { createWorkCommandTools } from "../tools/work-commands";
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";
conversationRoute as attachments,
conversationRoute as route,
} from "../auth/conversation-route";
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
description:
"Turns actionable conversation into provenanced Signals and proposed Work.",
"Turns actionable conversation into provenanced Signals, proposed Work, and execution requests.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
sandbox: local({ cwd: ZOPU_CODE_PATH }),
tools: createSliceOneTools(id, env),
thinkingLevel: "high",
tools: [
...createSignalRoutingTools(id, env),
...createWorkCommandTools(id, env),
],
};
});

View File

@@ -1,15 +1,15 @@
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";
import { Hono } from "hono";
import { internalRoute } from "./auth/internal-route";
import { parseAgentEnv } from "./runtime/agent-env";
import {
cancelAgentOsAttempt,
executeAgentOsAttempt,
runtimeRegistry,
} from "./runtime/agent-os";
} from "./runtime/attempt-runner";
import { WorkAttemptExecutionError } from "./runtime/zopu-primitives";
const agentEnv = parseAgentEnv(process.env);
@@ -34,12 +34,12 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
const app = new Hono();
app.post("/internal/work-attempts/execute", async (context) => {
if (
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
) {
return context.json({ error: "Unauthorized" }, 401);
}
// Unauthenticated liveness probe used by the VDS Compose healthcheck and CI
// staging verification. It must not depend on Convex, Rivet, or the model
// provider; it only confirms the Flue Node process is accepting requests.
app.get("/health", (context) => context.json({ status: "ok" }));
app.post("/internal/work-attempts/execute", internalRoute, async (context) => {
try {
return context.json(await executeAgentOsAttempt(await context.req.json()));
} catch (error) {
@@ -65,21 +65,21 @@ app.post("/internal/work-attempts/execute", async (context) => {
}
});
app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
if (
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
) {
return context.json({ error: "Unauthorized" }, 401);
app.post(
"/internal/work-attempts/:workspaceKey/cancel",
internalRoute,
async (context) => {
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 });
}
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());

View File

@@ -1,30 +0,0 @@
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);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (!organizationId || organizationId !== context.req.param("id")) {
return context.json({ error: "Forbidden" }, 403);
}
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

@@ -0,0 +1,73 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
const bindTurnSubmission = makeFunctionReference<
"mutation",
{
readonly clientRequestId: string;
readonly submissionId: string;
readonly token: string;
readonly turnId: string;
},
null
>("conversationMessages:bindTurnSubmission");
const readSubmissionId = async (response: Response): Promise<string | null> => {
const body: unknown = await response
.clone()
.json()
.catch(() => null);
if (
typeof body !== "object" ||
body === null ||
!("submissionId" in body) ||
typeof body.submissionId !== "string"
) {
return null;
}
return body.submissionId;
};
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const conversationRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (!organizationId || organizationId !== context.req.param("id")) {
return context.json({ error: "Forbidden" }, 403);
}
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 next().then(async () => {
if (!context.res.ok) {
return context.res;
}
const submissionId = await readSubmissionId(context.res);
if (submissionId === null) {
return context.json({ error: "Flue admission response is invalid" }, 502);
}
const client = new ConvexHttpClient(env.CONVEX_URL);
await client.mutation(bindTurnSubmission, {
clientRequestId,
submissionId,
token: env.FLUE_DB_TOKEN,
turnId,
});
return context.res;
});
};

View File

@@ -0,0 +1,12 @@
import { parseAgentEnv } from "@code/env/agent";
import type { MiddlewareHandler } from "hono";
/** Internal service authorization for execution/cancel routes. Not client-facing. */
export const internalRoute: MiddlewareHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
return await next();
};

View File

@@ -0,0 +1,39 @@
import { parseAgentEnv } from "@code/env/agent";
import type { WorkflowRouteHandler } from "@flue/runtime";
import * as v from "valibot";
const WorkflowInputEnvelope = v.object({
input: v.object({
organizationId: v.string(),
requestId: v.string(),
}),
});
/** Service authorization for finite Flue workflows. No turn headers required. */
export const workflowRoute: WorkflowRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
const authorization = context.req.header("authorization");
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
return context.json({ error: "Unauthorized" }, 401);
}
if (context.req.method !== "POST") {
return await next();
}
const body = (await context.req.json().catch(() => null)) as unknown;
const parsed = v.safeParse(WorkflowInputEnvelope, body);
if (!parsed.success) {
return context.json({ error: "Invalid workflow input envelope" }, 400);
}
const organizationId = context.req.header("x-zopu-organization-id");
if (
!organizationId ||
organizationId !== parsed.output.input.organizationId
) {
return context.json({ error: "Forbidden" }, 403);
}
return await next();
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
You are Zopu for product Slice 1 and the Work planning handoff.
You are Zopu, the conversational agent that turns user messages into provenanced Signals, proposed Work, and execution requests.
## Your role
@@ -12,23 +12,18 @@ When a user sends a message, follow this decision flow:
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.
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.
3. **Create a Signal (only when 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.
4. **Route 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`. d. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
5. **Execution requests.** When a user explicitly asks to implement or start work on a ready Work unit, call `start_work` with the work ID. The system handles planning, execution, verification, and delivery. Do not claim implementation has started until the tool returns a runId.
6. **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]."
- "Started execution of [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
@@ -39,18 +34,7 @@ When a user sends a message, follow this decision flow:
- 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.
## 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.
- Never claim you created a Signal, started Work, or produced a result until the tool call returns successfully.
- Do not perform implementation, planning, design, verification, or delivery yourself. Those are handled by dedicated workflows and the execution runtime.
- You have no repository access. You cannot run shell commands, inspect code, manage Git, or create pull requests. Focus on conversation, Signal capture, and Work orchestration.
- Proposed Work is the only Work status you directly create. The system advances Work through planning (Definition, Design) automatically. Work becomes ready for execution when planning completes.

View File

@@ -1,3 +1,3 @@
import { runtimeRegistry } from "./runtime/agent-os";
import { runtimeRegistry } from "./runtime/agent-os-registry";
await runtimeRegistry.startAndWait();

View File

@@ -0,0 +1 @@
export { parseAgentEnv } from "../../../env/src/agent";

View File

@@ -0,0 +1,27 @@
import { agentOS, setup } from "@rivet-dev/agentos";
import { makePiAgentOsConfig } from "./zopu-primitives";
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
const piConfig = makePiAgentOsConfig();
/** AgentOS workspace actor. Only the registry runner serves this actor type. */
export 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 } });

View File

@@ -1,37 +1,20 @@
import { parseAgentEnv } from "@code/env/agent";
import {
makePiAgentOsConfig,
makePiHomeFiles,
decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError,
piSessionEnv,
} from "@code/primitives";
import type {
ExecutionEvent,
WorkAttemptExecutionResult,
} 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";
import { HostRepositoryWorkspace } from "./host-repository";
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
}
},
options: {
actionTimeout: 10 * 60 * 1000,
},
permissions: piConfig.permissions,
software: piConfig.software,
});
export const runtimeRegistry = setup({ use: { workspace } });
import { parseAgentEnv } from "./agent-env";
import type { runtimeRegistry } from "./agent-os-registry";
import { classifyRuntimeFailure, executionError } from "./execution-errors";
import { RepositoryWorkspace } from "./repository-workspace";
import {
decodeWorkAttemptExecutionInput,
makePiHomeFiles,
piSessionEnv,
} from "./zopu-primitives";
import type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "./zopu-primitives";
const event = (
sequence: number,
@@ -46,32 +29,6 @@ const event = (
sequence,
});
const executionError = (
message: string,
reason: WorkAttemptExecutionError["reason"],
retryable: boolean
) => new WorkAttemptExecutionError({ message, reason, retryable });
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> => {
@@ -83,6 +40,7 @@ export const executeAgentOsAttempt = async (
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], {
@@ -93,9 +51,16 @@ export const executeAgentOsAttempt = async (
workspaceKey: input.workspaceKey,
}),
];
const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({
const repository = new RepositoryWorkspace(
env.AGENT_WORKSPACE_ROOT,
true,
env.BUN_EXECUTABLE
);
const prepared = await repository.prepare({
attemptId: input.attemptId,
baseBranch: input.baseBranch,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
piHomeFiles: makePiHomeFiles({
api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
@@ -105,14 +70,16 @@ export const executeAgentOsAttempt = async (
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
? "Isolated Zopu worktree created on the execution host"
: "Isolated Zopu worktree recreated"
? "Isolated repository clone created on the execution host"
: "Isolated repository clone recreated"
)
);
const mounts = [
@@ -121,11 +88,6 @@ export const executeAgentOsAttempt = async (
path: "/workspace/repository",
readOnly: false,
},
{
hostPath: prepared.sourceRepositoryPath,
path: prepared.sourceRepositoryPath,
readOnly: false,
},
{
hostPath: prepared.piHomePath,
path: "/home/zopu",
@@ -171,18 +133,15 @@ export const executeAgentOsAttempt = async (
const sessionId = `pi-${input.attemptId}`;
await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
additionalInstructions:
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
"Work only inside /workspace/repository. This is an isolated clone of the project repository. Read the project documentation and conventions before changing code. Never reveal credentials, push, create branches, 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")
);
events.push(event(3, "harness.started", "Implementation session started"));
const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }],
idempotencyKey: input.attemptId,
@@ -192,19 +151,19 @@ export const executeAgentOsAttempt = async (
const reason =
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
throw executionError(
`Pi stopped with ${promptResult.stopReason}`,
`Agent stopped with ${promptResult.stopReason}`,
reason,
promptResult.stopReason === "max_tokens" ||
promptResult.stopReason === "max_turn_requests"
);
}
events.push(
event(4, "harness.progress", "Pi implementation turn completed", {
event(4, "harness.progress", "Implementation turn completed", {
stopReason: promptResult.stopReason,
})
);
const collected = await HostRepositoryWorkspace.collect({
const collected = await RepositoryWorkspace.collect({
attemptId: input.attemptId,
baseRevision,
checkoutPath: prepared.checkoutPath,
@@ -215,9 +174,7 @@ export const executeAgentOsAttempt = async (
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
{
candidateRevision,
}
{ candidateRevision }
),
event(6, "runtime.completed", "AgentOS execution completed")
);
@@ -231,8 +188,8 @@ export const executeAgentOsAttempt = async (
events,
summary:
changedFiles.length > 0
? `Pi changed ${changedFiles.length} file(s)`
: "Pi completed without repository changes",
? `Agent changed ${changedFiles.length} file(s)`
: "Agent completed without repository changes",
};
} catch (error) {
throw classifyRuntimeFailure(error);
@@ -247,6 +204,7 @@ export const cancelAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
await client.workspace

View File

@@ -0,0 +1,29 @@
import { WorkAttemptExecutionError } from "./zopu-primitives";
export const executionError = (
message: string,
reason: WorkAttemptExecutionError["reason"],
retryable: boolean
) => new WorkAttemptExecutionError({ message, reason, retryable });
export 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);
};

View File

@@ -1,4 +1,4 @@
import { execFileSync, spawn } from "node:child_process";
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import {
@@ -11,10 +11,18 @@ import {
} from "node:fs/promises";
import path from "node:path";
import type { PiHomeFiles } from "@code/primitives";
import type { PiHomeFiles } from "./zopu-primitives";
interface PrepareRepositoryInput {
attemptId: string;
baseBranch?: 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;
}
@@ -61,20 +69,40 @@ const changedFilePath = (line: string): string => {
: filePath.slice(renameIndex + renameSeparator.length);
};
// Minimal allow-list so untrusted repo code (postinstall scripts, hooks)
// can never read host secrets like tokens or API keys from the inherited env.
// PATH is needed for git/bun; locale vars prevent encoding issues.
// HOME is intentionally excluded — the runner gets its own /home/zopu mount,
// and forwarding the host HOME would expose host-level dotfiles and credentials.
const SAFE_ENV_KEYS = [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TZ",
] as const;
const safeBaseEnv = (): Record<string, string> => {
const env: Record<string, string> = {};
for (const key of SAFE_ENV_KEYS) {
const value = process.env[key];
if (value !== undefined) {
env[key] = value;
}
}
return env;
};
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 },
env: { ...safeBaseEnv(), ...env },
});
const stderr: Uint8Array[] = [];
const stdout: Uint8Array[] = [];
@@ -100,29 +128,22 @@ const pathExists = async (target: string): Promise<boolean> => {
}
};
export class HostRepositoryWorkspace {
export class RepositoryWorkspace {
readonly #root: string;
readonly #sourceRepositoryPath: string;
readonly #installDependencies: boolean;
readonly #bunExecutable: string;
constructor(
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
"/opt/zopu-source",
installDependencies = true
root = "/srv/zopu/workspaces",
installDependencies = true,
bunExecutable = "bun"
) {
this.#root = root;
this.#sourceRepositoryPath = sourceRepositoryPath;
this.#installDependencies = installDependencies;
this.#bunExecutable = bunExecutable;
}
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
throw new Error(
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
);
}
const identity = createHash("sha256")
.update(input.attemptId)
.digest("hex")
@@ -131,42 +152,60 @@ export class HostRepositoryWorkspace {
const checkoutPath = path.join(workspacePath, "repository");
const toolsPath = path.join(workspacePath, "tools");
const piHomePath = path.join(workspacePath, "home");
const branch = `zopu/attempt-${identity}`;
const created = !(await pathExists(path.join(checkoutPath, ".git")));
await mkdir(workspacePath, { recursive: true });
if (!created) {
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 runGit(this.#sourceRepositoryPath, [
"worktree",
"remove",
"--force",
checkoutPath,
]),
"Existing worktree removal"
await runProcess(
"git",
["clone", "--no-tags", input.cloneUrl, checkoutPath],
workspacePath,
cloneEnv
),
"Repository clone"
);
} finally {
// The credential only existed on the local cloneEnv object, never on
// process.env. The askpass script is cleaned up with the workspace.
cloneEnv.ZOPU_GIT_TOKEN = "";
}
if (input.baseBranch) {
requireSuccess(
await runGit(checkoutPath, ["checkout", input.baseBranch]),
"Base branch checkout"
);
}
await rm(checkoutPath, { force: true, recursive: true });
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"add",
"-B",
branch,
checkoutPath,
"HEAD",
]),
"Zopu worktree creation"
);
const bunExecutable = this.#bunExecutable;
const bunExecutable =
process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
if (await pathExists(sourceEnvPath)) {
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
}
if (this.#installDependencies) {
requireSuccess(
await runProcess(
@@ -204,7 +243,7 @@ export class HostRepositoryWorkspace {
checkoutPath,
created,
piHomePath,
sourceRepositoryPath: this.#sourceRepositoryPath,
sourceRepositoryPath: checkoutPath,
toolsPath,
};
}

View File

@@ -0,0 +1,14 @@
export {
decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError,
} from "../../../primitives/src/execution-runtime";
export type {
ExecutionEvent,
WorkAttemptExecutionResult,
} from "../../../primitives/src/execution-runtime";
export {
makePiAgentOsConfig,
makePiHomeFiles,
piSessionEnv,
} from "../../../primitives/src/agent-os";
export type { PiHomeFiles } from "../../../primitives/src/agent-os";

View File

@@ -1,9 +1,9 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
import { createConvexServiceClient } from "../adapters/convex-client";
const listProjectsRef = makeFunctionReference<
"query",
{ organizationId: string; token: string },
@@ -52,13 +52,11 @@ const attachSignalRef = makeFunctionReference<
{ attached: boolean; workId: string }
>("works:attachSignalToWork");
export const createSliceOneTools = (
export const createSignalRoutingTools = (
organizationId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
const { client, token } = createConvexServiceClient(runtimeEnv);
return [
defineTool({

View File

@@ -0,0 +1,79 @@
import { defineTool } from "@flue/runtime";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
import { createConvexServiceClient } from "../adapters/convex-client";
const startWorkRef = makeFunctionReference<
"mutation",
{
organizationId: string;
sliceId?: string;
token: string;
workId: string;
},
{ runId: string }
>("workExecution:start");
const cancelWorkRef = makeFunctionReference<
"mutation",
{ organizationId: string; runId: string; token: string },
{ cancelled: boolean }
>("workExecution:cancel");
const retryWorkRef = makeFunctionReference<
"mutation",
{ organizationId: string; runId: string; token: string },
{ runId: string }
>("workExecution:retry");
export const createWorkCommandTools = (
organizationId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const { client, token } = createConvexServiceClient(runtimeEnv);
return [
defineTool({
description:
"Start execution of a ready Work unit. Returns the runId. Use only when the user explicitly requests implementation.",
input: v.object({
sliceId: v.optional(v.string()),
workId: v.string(),
}),
name: "start_work",
async run({ input }) {
return await client.mutation(startWorkRef, {
organizationId,
sliceId: input.sliceId,
token,
workId: input.workId,
});
},
}),
defineTool({
description: "Cancel a running Work execution by runId.",
input: v.object({ runId: v.string() }),
name: "cancel_work",
async run({ input }) {
return await client.mutation(cancelWorkRef, {
organizationId,
runId: input.runId,
token,
});
},
}),
defineTool({
description: "Retry a failed or cancelled Work execution by runId.",
input: v.object({ runId: v.string() }),
name: "retry_work",
async run({ input }) {
return await client.mutation(retryWorkRef, {
organizationId,
runId: input.runId,
token,
});
},
}),
];
};

View File

@@ -1,69 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
const submitDefinitionRef = makeFunctionReference<
"mutation",
{ workId: string; payloadJson: string; token: string },
{ version: number }
>("workPlanning:submitDefinitionProposal");
const submitDesignRef = makeFunctionReference<
"mutation",
{ workId: string; payloadJson: string; token: string },
{ version: number }
>("workPlanning:submitDesignProposal");
const submitQuestionRef = makeFunctionReference<
"mutation",
{ workId: string; questionJson: string; token: string },
{ accepted: boolean }
>("workPlanning:submitQuestion");
export const createWorkPlannerTools = (
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
return [
defineTool({
description:
"Submit a versioned Work Definition proposal. This never approves the Definition.",
input: v.object({ definitionJson: v.string(), workId: v.string() }),
name: "submit_definition_proposal",
async run({ input }) {
return await client.mutation(submitDefinitionRef, {
payloadJson: input.definitionJson,
token,
workId: input.workId,
});
},
}),
defineTool({
description:
"Submit a Design Packet with observable independently verifiable slices. This never approves the Design.",
input: v.object({ designJson: v.string(), workId: v.string() }),
name: "submit_design_proposal",
async run({ input }) {
return await client.mutation(submitDesignRef, {
payloadJson: input.designJson,
token,
workId: input.workId,
});
},
}),
defineTool({
description: "Submit one structured question for human attention.",
input: v.object({ questionJson: v.string(), workId: v.string() }),
name: "submit_question",
async run({ input }) {
return await client.mutation(submitQuestionRef, {
questionJson: input.questionJson,
token,
workId: input.workId,
});
},
}),
];
};

View File

@@ -0,0 +1,121 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent, defineWorkflow } from "@flue/runtime";
import * as v from "valibot";
export { workflowRoute as route } from "../auth/workflow-route";
// --- Input ---
const questionSchema = v.object({
alternatives: v.array(v.string()),
id: v.string(),
impact: v.picklist(["low", "medium", "high"]),
prompt: v.string(),
recommendation: v.optional(v.string()),
});
const planWorkInput = v.object({
context: v.object({
existingDefinition: v.optional(v.string()),
problemStatement: v.string(),
}),
organizationId: v.string(),
phase: v.picklist(["definition", "design"]),
requestId: v.string(),
workId: v.string(),
});
// --- Output ---
const definitionSchema = v.object({
acceptanceCriteria: v.array(v.string()),
affectedUsers: v.array(v.string()),
assumptions: v.array(v.string()),
constraints: v.array(v.string()),
desiredOutcome: v.string(),
inScope: v.array(v.string()),
outOfScope: v.array(v.string()),
problem: v.string(),
requiredArtifacts: v.array(v.string()),
risk: v.picklist(["low", "medium", "high"]),
});
const sliceSchema = v.object({
codeBoundaries: v.array(v.string()),
dependsOn: v.array(v.string()),
evidenceRequirements: v.array(v.string()),
id: v.string(),
objective: v.string(),
observableBehavior: v.string(),
reviewRequired: v.boolean(),
title: v.string(),
verification: v.array(v.string()),
});
const designSchema = v.object({
architectureSummary: v.string(),
callFlowDelta: v.array(v.string()),
concerns: v.array(v.string()),
evidenceRequirements: v.array(v.string()),
fileTreeDelta: v.array(v.string()),
invariants: v.array(v.string()),
keyInterfaces: v.array(v.string()),
slices: v.array(sliceSchema),
tradeoffs: v.array(v.string()),
});
const planWorkOutput = v.union([
v.object({
definition: definitionSchema,
phase: v.literal("definition"),
questions: v.array(questionSchema),
}),
v.object({
design: designSchema,
phase: v.literal("design"),
questions: v.array(questionSchema),
}),
]);
// --- Agent ---
const planWorkAgent = defineAgent(({ env }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
description:
"Produces a Work Definition or Design Packet with structured questions.",
instructions: `You are Zopu's Work Planner. You receive a Work problem statement and produce either a Definition or a Design Packet depending on the requested phase.
For the definition phase, produce a precise problem statement, desired outcome, acceptance criteria, scope boundaries, assumptions, constraints, and risk level. Ask focused high-impact questions only when a decision genuinely blocks the work.
For the design phase, produce an architecture summary, file tree delta, call flow delta, key interfaces, invariants, evidence requirements, 1-4 observable and independently verifiable slices, tradeoffs, and concerns. Each slice must have a clear objective, observable behavior, verification steps, and code boundaries. Dependencies must reference earlier slices.
Never approve, execute, or claim implementation. Your output is a proposal that Convex validates and persists.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "high",
};
});
// --- Workflow ---
export default defineWorkflow({
agent: planWorkAgent,
input: planWorkInput,
output: planWorkOutput,
async run({ input, harness }) {
const session = await harness.session();
const instruction =
input.phase === "definition"
? `Produce a Work Definition for the following problem:\n\n${input.context.problemStatement}`
: `Produce a Design Packet based on this approved Definition:\n\n${input.context.existingDefinition ?? input.context.problemStatement}`;
const response = await session.prompt(instruction, {
result: planWorkOutput,
});
if (response.data.phase !== input.phase) {
throw new Error(
`Planner returned phase "${response.data.phase}" but "${input.phase}" was requested`
);
}
return response.data;
},
});

View File

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

View File

@@ -1,6 +1,9 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@code/env/native";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import {
convexClient,
crossDomainClient,
} from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
@@ -9,7 +12,7 @@ import { Platform } from "react-native";
const scheme = Constants.expoConfig?.scheme;
if (typeof scheme !== "string") {
throw new Error("Expo auth requires a string app scheme in app.json");
throw new TypeError("Expo auth requires a string app scheme in app.json");
}
export const authClient = createAuthClient({

View File

@@ -1,12 +1,14 @@
import { useForm } from "@tanstack/react-form";
import { useToast } from "heroui-native";
import { getErrorMessage, signInSchema, signUpSchema } from "../shared/forms";
import { signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
export { getErrorMessage } from "../shared/forms";
interface AuthFormOptions {
readonly onSuccess?: () => void;
};
}
export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
@@ -18,14 +20,17 @@ export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({ label: error.message || "Failed to sign in", variant: "danger" });
toast.show({
label: error.message || "Failed to sign in",
variant: "danger",
});
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Signed in successfully", variant: "success" });
onSuccess?.();
},
},
}
);
},
validators: { onSubmit: signInSchema },
@@ -39,7 +44,11 @@ export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
email: value.email.trim(),
name: value.name.trim(),
password: value.password,
},
{
onError: ({ error }) => {
toast.show({
@@ -49,14 +58,15 @@ export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Account created successfully", variant: "success" });
toast.show({
label: "Account created successfully",
variant: "success",
});
onSuccess?.();
},
},
}
);
},
validators: { onSubmit: signUpSchema },
});
};
export { getErrorMessage };

View File

@@ -9,14 +9,15 @@ import {
useThemeColor,
} from "heroui-native";
import { useRef } from "react";
import { Text, type TextInput, View } from "react-native";
import { Text, View } from "react-native";
import type { TextInput } from "react-native";
import { getErrorMessage, useNativeSignInForm } from "./hooks";
type NativeLoginFormProps = {
interface NativeLoginFormProps {
readonly onNavigateSignUp: () => void;
readonly onSuccess?: () => void;
};
}
export const NativeLoginForm = ({
onNavigateSignUp,
@@ -29,7 +30,14 @@ export const NativeLoginForm = ({
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
<Text
style={{
color: foregroundColor,
fontSize: 24,
fontWeight: "600",
marginBottom: 4,
}}
>
Welcome back
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
@@ -93,7 +101,11 @@ export const NativeLoginForm = ({
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? <Spinner color="default" size="sm" /> : <Button.Label>Sign in</Button.Label>}
{isSubmitting ? (
<Spinner color="default" size="sm" />
) : (
<Button.Label>Sign in</Button.Label>
)}
</Button>
<Button onPress={onNavigateSignUp} variant="ghost">

View File

@@ -12,10 +12,10 @@ import { Text, View } from "react-native";
import { getErrorMessage, useNativeSignUpForm } from "./hooks";
type NativeSignupFormProps = {
interface NativeSignupFormProps {
readonly onNavigateSignIn: () => void;
readonly onSuccess?: () => void;
};
}
export const NativeSignupForm = ({
onNavigateSignIn,
@@ -27,7 +27,14 @@ export const NativeSignupForm = ({
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
<Text
style={{
color: foregroundColor,
fontSize: 24,
fontWeight: "600",
marginBottom: 4,
}}
>
Create an account
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>

View File

@@ -1,16 +1,30 @@
import { z } from "zod";
export const signInSchema = z.object({
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
});
export const signUpSchema = z
.object({
confirmPassword: z.string().min(1, "Confirm your password"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
name: z.string().trim().min(2, "Name must be at least 2 characters"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
})
.refine(({ confirmPassword, password }) => confirmPassword === password, {
message: "Passwords do not match",
@@ -18,13 +32,19 @@ export const signUpSchema = z
});
export const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (typeof error === "string") return error;
if (!error) {
return null;
}
if (typeof error === "string") {
return error;
}
if (Array.isArray(error)) {
for (const issue of error) {
const message = getErrorMessage(issue);
if (message) return message;
if (message) {
return message;
}
}
return null;
}

View File

@@ -38,7 +38,9 @@ export const LoginForm = ({
<Card>
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>Enter your email and password to continue.</CardDescription>
<CardDescription>
Enter your email and password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form
@@ -58,7 +60,9 @@ export const LoginForm = ({
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder="you@example.com"
type="email"
value={field.state.value}
@@ -77,7 +81,9 @@ export const LoginForm = ({
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
onChange={(event) =>
field.handleChange(event.target.value)
}
type="password"
value={field.state.value}
/>
@@ -100,7 +106,8 @@ export const LoginForm = ({
{isSubmitting ? "Signing in…" : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href={signUpHref}>Sign up</a>
Don&apos;t have an account?{" "}
<a href={signUpHref}>Sign up</a>
</FieldDescription>
</Field>
)}

View File

@@ -35,7 +35,9 @@ export const SignupForm = ({
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Use your name, email, and password to get started.</CardDescription>
<CardDescription>
Use your name, email, and password to get started.
</CardDescription>
</CardHeader>
<CardContent>
<form
@@ -96,7 +98,9 @@ export const SignupForm = ({
type="password"
value={field.state.value}
/>
<FieldDescription>Use at least 8 characters.</FieldDescription>
<FieldDescription>
Use at least 8 characters.
</FieldDescription>
<FieldError errors={field.state.meta.errors} />
</Field>
)}

View File

@@ -2,12 +2,8 @@
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read
`convex/_generated/ai/guidelines.md` first** for important guidelines on
how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running
`npx convex ai-files install`.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->

View File

@@ -1,6 +1,6 @@
# Convex Backend
Convex is the only application API used by web, desktop, and mobile clients. The active backend covers the durable control plane through Slice 4: tenancy, projects, conversation turns, Signals, Work planning, approved Design slices, simulated Runs and Attempts, artifacts/delivery metadata, and Flue persistence.
Convex is the only application API used by web, desktop, and mobile clients. The active backend covers the durable control plane: tenancy, projects, conversation turns, Signals, Work planning (Definition/Design without approval gates), real Runs and Attempts through AgentOS, artifacts/delivery metadata, and Flue persistence.
## Domain relations
@@ -9,9 +9,9 @@ Convex is the only application API used by web, desktop, and mobile clients. The
- `conversations`, `conversationTurns`, `conversationMessages`, and `conversationAttachments` own chat admission and reactive history.
- `signals`, `signalConstraints`, and `signalSources` preserve structured intent and exact evidence.
- `works`, `signalWorkAttachments`, and `workEvents` own proposed Work and provenance.
- `workDefinitions`, `workQuestions`, and `workApprovals` own versioned outcome contracts and approvals.
- `workDefinitions` and `workQuestions` own versioned outcome contracts and structured questions.
- `designPackets` and `workSlices` preserve versioned implementation intent and executable slice order.
- `workRuns`, `workAttempts`, `workAttemptEvents`, and `resolverDecisions` own bounded simulated execution and recovery.
- `workRuns`, `workAttempts`, `workAttemptEvents`, and `resolverDecisions` own bounded real execution and recovery.
- `workArtifacts` and `workDeliveries` preserve evidence and delivery metadata without performing Git or sandbox work.
The `flue*` tables are infrastructure tables required by Flue's persistence contract. They are deliberately separate from the product relations.

View File

@@ -8,6 +8,7 @@
* @module
*/
import type * as agentBackend from "../agentBackend.js";
import type * as auth from "../auth.js";
import type * as authz from "../authz.js";
import type * as conversationMessages from "../conversationMessages.js";
@@ -15,7 +16,10 @@ 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";
import type * as gitConnectionHealth from "../gitConnectionHealth.js";
import type * as gitConnections from "../gitConnections.js";
import type * as gitProvisioning from "../gitProvisioning.js";
import type * as gitWebhooks from "../gitWebhooks.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as organizations from "../organizations.js";
@@ -25,8 +29,6 @@ import type * as publicGit from "../publicGit.js";
import type * as signalRouting from "../signalRouting.js";
import type * as workArtifacts from "../workArtifacts.js";
import type * as workExecution from "../workExecution.js";
import type * as workExecutionAgent from "../workExecutionAgent.js";
import type * as workExecutionWorkflow from "../workExecutionWorkflow.js";
import type * as workPlanning from "../workPlanning.js";
import type * as works from "../works.js";
@@ -37,6 +39,7 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
agentBackend: typeof agentBackend;
auth: typeof auth;
authz: typeof authz;
conversationMessages: typeof conversationMessages;
@@ -44,7 +47,10 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons;
fluePersistence: typeof fluePersistence;
gitConnectionData: typeof gitConnectionData;
gitConnectionHealth: typeof gitConnectionHealth;
gitConnections: typeof gitConnections;
gitProvisioning: typeof gitProvisioning;
gitWebhooks: typeof gitWebhooks;
healthCheck: typeof healthCheck;
http: typeof http;
organizations: typeof organizations;
@@ -54,8 +60,6 @@ declare const fullApi: ApiFromModules<{
signalRouting: typeof signalRouting;
workArtifacts: typeof workArtifacts;
workExecution: typeof workExecution;
workExecutionAgent: typeof workExecutionAgent;
workExecutionWorkflow: typeof workExecutionWorkflow;
workPlanning: typeof workPlanning;
works: typeof works;
}>;

View File

@@ -30,10 +30,13 @@ type Env = {
readonly FLUE_URL: string | undefined;
readonly GITEA_TOKEN: string | undefined;
readonly GITEA_URL: string | undefined;
readonly GITEA_WEBHOOK_SECRET: string | undefined;
readonly GITHUB_CLIENT_ID: string | undefined;
readonly GITHUB_CLIENT_SECRET: string | undefined;
readonly GITHUB_WEBHOOK_SECRET: string | undefined;
readonly GIT_CREDENTIAL_ENCRYPTION_KEY: string | undefined;
readonly NATIVE_APP_URL: string | undefined;
readonly PUTER_GIT_ADMIN_TOKEN: string | undefined;
readonly SITE_URL: string;
};

View File

@@ -6,33 +6,55 @@ import {
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({
args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => {
const running = await ctx.runMutation(
internal.workExecutionWorkflow.markAttemptRunning,
internal.workExecution.markAttemptRunning,
args
);
if (!running) {
throw new ConvexError("Attempt is no longer runnable");
}
const context = await ctx.runQuery(
internal.workExecutionWorkflow.executionContext,
internal.workExecution.executionContext,
args
);
const credential = await decryptCredential(
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`,
{
@@ -44,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,

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