Compare commits
18 Commits
a8b2ff5e2e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6364c33499 | ||
|
|
db9afd7a24 | ||
|
|
ce16813fbc | ||
|
|
95c23db4fc | ||
|
|
08bc5ae259 | ||
|
|
4dc878b8cb | ||
|
|
3e3f06403f | ||
|
|
8bd3953440 | ||
|
|
bb4bd9e0d3 | ||
|
|
f208d4e4b8 | ||
|
|
fc1d0b6916 | ||
|
|
fc8cfabacb | ||
|
|
ce1f33aa69 | ||
|
|
c644ec8d01 | ||
|
|
88f53f550d | ||
|
|
54a5993274 | ||
|
|
def3fc949a | ||
|
|
9f7dd3c1a6 |
35
.dockerignore
Normal file
35
.dockerignore
Normal 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
|
||||||
@@ -20,10 +20,12 @@ DAEMON_COMMAND_LEASE_MS=60000
|
|||||||
RIVET_ENDPOINT=http://localhost:6420
|
RIVET_ENDPOINT=http://localhost:6420
|
||||||
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
||||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
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 persistence adapter
|
||||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
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
|
||||||
AGENT_MODEL_PROVIDER=xiaomi
|
AGENT_MODEL_PROVIDER=xiaomi
|
||||||
|
|||||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -55,3 +55,24 @@ coverage
|
|||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
.env.*
|
.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
17
.vercelignore
Normal 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/
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
|
"@vercel/react-router": "1.3.1",
|
||||||
"react-router-devtools": "^6.2.1",
|
"react-router-devtools": "^6.2.1",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
import { vercelPreset } from "@vercel/react-router/vite";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
appDirectory: "src",
|
appDirectory: "src",
|
||||||
|
presets: [vercelPreset()],
|
||||||
ssr: true,
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
74
apps/web/src/components/projects/add-project-panel.tsx
Normal file
74
apps/web/src/components/projects/add-project-panel.tsx
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
70
apps/web/src/components/projects/context-editor.tsx
Normal file
70
apps/web/src/components/projects/context-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
apps/web/src/components/projects/github-connect-button.tsx
Normal file
36
apps/web/src/components/projects/github-connect-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
apps/web/src/components/projects/loading-state.tsx
Normal file
7
apps/web/src/components/projects/loading-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
76
apps/web/src/components/projects/project-card.tsx
Normal file
76
apps/web/src/components/projects/project-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
65
apps/web/src/components/projects/projects-grid.tsx
Normal 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>
|
||||||
|
);
|
||||||
51
apps/web/src/components/projects/projects-header.tsx
Normal file
51
apps/web/src/components/projects/projects-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
166
apps/web/src/components/projects/provider-chips.tsx
Normal file
166
apps/web/src/components/projects/provider-chips.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
apps/web/src/components/projects/provider-section.tsx
Normal file
46
apps/web/src/components/projects/provider-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal file
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
346
apps/web/src/components/projects/repository-selector.tsx
Normal file
346
apps/web/src/components/projects/repository-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export function ThemeProvider({
|
export const ThemeProvider = ({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
}: React.ComponentProps<typeof NextThemesProvider>) => (
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
<NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
}
|
);
|
||||||
|
|
||||||
export { useTheme } from "next-themes";
|
export { useTheme } from "next-themes";
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const ConversationComposer = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Message Zopu"
|
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}
|
disabled={busy}
|
||||||
onChange={(event) => onDraftChange(event.target.value)}
|
onChange={(event) => onDraftChange(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Button } from "@code/ui/components/button";
|
import { FolderGit2, Menu } from "lucide-react";
|
||||||
import { Menu, Settings } from "lucide-react";
|
import { Link } from "react-router";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
import { ProjectSettingsPanel } from "./project-settings-panel";
|
|
||||||
|
|
||||||
export const ProjectHeader = ({
|
export const ProjectHeader = ({
|
||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -13,11 +10,10 @@ export const ProjectHeader = ({
|
|||||||
readonly onOpenDrawer: () => void;
|
readonly onOpenDrawer: () => void;
|
||||||
readonly workspace: WorkspaceState;
|
readonly workspace: WorkspaceState;
|
||||||
}) => {
|
}) => {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const works = workspace.works ?? [];
|
const works = workspace.works ?? [];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
<select
|
<select
|
||||||
aria-label="Current project"
|
aria-label="Current project"
|
||||||
@@ -35,15 +31,13 @@ export const ProjectHeader = ({
|
|||||||
Conversation to proposed Work
|
Conversation to proposed Work
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Link
|
||||||
aria-label="Project settings"
|
aria-label="Projects"
|
||||||
className="mr-2 size-9"
|
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
|
||||||
onClick={() => setSettingsOpen((open) => !open)}
|
to="/projects"
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
<Settings className="size-4" />
|
<FolderGit2 className="size-4" />
|
||||||
</Button>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||||
onClick={onOpenDrawer}
|
onClick={onOpenDrawer}
|
||||||
@@ -52,12 +46,6 @@ export const ProjectHeader = ({
|
|||||||
<Menu className="size-4" /> Work{" "}
|
<Menu className="size-4" /> Work{" "}
|
||||||
{workspace.works === undefined ? "…" : works.length}
|
{workspace.works === undefined ? "…" : works.length}
|
||||||
</button>
|
</button>
|
||||||
{settingsOpen ? (
|
|
||||||
<ProjectSettingsPanel
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
workspace={workspace}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button } from "@code/ui/components/button";
|
import { Button } from "@code/ui/components/button";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
Hammer,
|
Hammer,
|
||||||
@@ -32,16 +31,13 @@ const starterDefinition = (work: WorkRecord) => ({
|
|||||||
outOfScope: ["Unrelated product changes"],
|
outOfScope: ["Unrelated product changes"],
|
||||||
problem: work.objective,
|
problem: work.objective,
|
||||||
questions: [],
|
questions: [],
|
||||||
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
requiredArtifacts: ["Implementation diff and terminal outcome"],
|
||||||
risk: "medium",
|
risk: "medium",
|
||||||
});
|
});
|
||||||
|
|
||||||
const starterDesign = (work: WorkRecord) => ({
|
const starterDesign = (work: WorkRecord) => ({
|
||||||
architectureSummary:
|
architectureSummary: "Implement the requested outcome in focused changes.",
|
||||||
"Validate the approved Definition, then exercise one deterministic fake slice.",
|
callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
|
||||||
callFlowDelta: [
|
|
||||||
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
|
|
||||||
],
|
|
||||||
concerns: [],
|
concerns: [],
|
||||||
evidenceRequirements: ["Terminal Run classification"],
|
evidenceRequirements: ["Terminal Run classification"],
|
||||||
fileTreeDelta: [],
|
fileTreeDelta: [],
|
||||||
@@ -49,30 +45,30 @@ const starterDesign = (work: WorkRecord) => ({
|
|||||||
files: [],
|
files: [],
|
||||||
modules: [],
|
modules: [],
|
||||||
risks: [],
|
risks: [],
|
||||||
summary: "Compact vertical-slice simulation",
|
summary: "Focused implementation",
|
||||||
},
|
},
|
||||||
invariants: [
|
invariants: [
|
||||||
"Simulation never claims implementation",
|
"A successful AgentOS response alone never marks Work complete",
|
||||||
"Every Attempt reaches a terminal classification",
|
"Every Attempt reaches a terminal classification",
|
||||||
],
|
],
|
||||||
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
keyInterfaces: ["WorkExecution", "AttemptResult"],
|
||||||
slices: [
|
slices: [
|
||||||
{
|
{
|
||||||
codeBoundaries: ["workExecution"],
|
codeBoundaries: ["workExecution"],
|
||||||
dependsOn: [],
|
dependsOn: [],
|
||||||
evidenceRequirements: ["Normalized activity events"],
|
evidenceRequirements: ["Normalized activity events"],
|
||||||
id: "deterministic-simulation",
|
id: "implementation",
|
||||||
objective: work.objective,
|
objective: work.objective,
|
||||||
observableBehavior: "A terminal fake Run is visible",
|
observableBehavior: "Implementation changes are visible in the diff",
|
||||||
reviewRequired: false,
|
reviewRequired: false,
|
||||||
title: "Deterministic simulation",
|
title: "Implementation",
|
||||||
verification: ["Run completes with a terminal classification"],
|
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 = ({
|
export const WorkCard = ({
|
||||||
onSourceSelect,
|
onSourceSelect,
|
||||||
work,
|
work,
|
||||||
@@ -95,7 +91,7 @@ export const WorkCard = ({
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||||
Proposed Work
|
{work.status === "proposed" ? "Proposed Work" : "Work"}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||||
{work.title}
|
{work.title}
|
||||||
@@ -149,7 +145,8 @@ export const WorkCard = ({
|
|||||||
Outcome
|
Outcome
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</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"}
|
Risk: {work.definition?.risk ?? "not defined"}
|
||||||
</p>
|
</p>
|
||||||
{openQuestions > 0 ? (
|
{openQuestions > 0 ? (
|
||||||
@@ -180,20 +177,6 @@ export const WorkCard = ({
|
|||||||
<Hammer className="size-3.5" /> Save definition
|
<Hammer className="size-3.5" /> Save definition
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -220,22 +203,6 @@ export const WorkCard = ({
|
|||||||
<Hammer className="size-3.5" /> Save design
|
<Hammer className="size-3.5" /> Save design
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -261,10 +228,7 @@ export const WorkCard = ({
|
|||||||
{latestRun ? (
|
{latestRun ? (
|
||||||
<div className="mt-1 space-y-2 text-[#626057]">
|
<div className="mt-1 space-y-2 text-[#626057]">
|
||||||
<p className="leading-5">
|
<p className="leading-5">
|
||||||
{latestRun.executionKind === "real"
|
Run {latestRun.status}:{" "}
|
||||||
? "AgentOS"
|
|
||||||
: "Simulation"}{" "}
|
|
||||||
run {latestRun.status}:{" "}
|
|
||||||
{latestRun.terminalSummary ??
|
{latestRun.terminalSummary ??
|
||||||
latestRun.terminalClassification ??
|
latestRun.terminalClassification ??
|
||||||
"activity is still arriving"}
|
"activity is still arriving"}
|
||||||
@@ -343,7 +307,6 @@ export const WorkCard = ({
|
|||||||
)}
|
)}
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{work.status === "ready" ? (
|
{work.status === "ready" ? (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!workspace.projectGitConnection}
|
disabled={!workspace.projectGitConnection}
|
||||||
@@ -351,37 +314,22 @@ export const WorkCard = ({
|
|||||||
>
|
>
|
||||||
<Play className="size-3.5" /> Run
|
<Play className="size-3.5" /> Run
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
void workspace.startSimulation(work._id, "success")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Play className="size-3.5" /> Simulate
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{latestRun?.status === "running" ? (
|
{latestRun?.status === "running" ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => void workspace.cancelExecution(latestRun._id)}
|
||||||
void (latestRun.executionKind === "real"
|
|
||||||
? workspace.cancelExecution(latestRun._id)
|
|
||||||
: workspace.cancelSimulation(latestRun._id))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<X className="size-3.5" /> Cancel
|
<X className="size-3.5" /> Cancel
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{latestRun?.executionKind !== "real" &&
|
{latestRun?.status === "terminal" &&
|
||||||
latestRun?.status === "terminal" &&
|
|
||||||
latestRun.terminalClassification === "RetryableFailure" ? (
|
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void workspace.retrySimulation(latestRun._id)}
|
onClick={() => void workspace.retryExecution(latestRun._id)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-3.5" /> Retry
|
<RotateCcw className="size-3.5" /> Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
25
apps/web/src/hooks/use-filtered-projects.ts
Normal 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]);
|
||||||
102
apps/web/src/hooks/use-github-repo-search.ts
Normal file
102
apps/web/src/hooks/use-github-repo-search.ts
Normal 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;
|
||||||
|
};
|
||||||
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
22
apps/web/src/hooks/use-projects-page-state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
64
apps/web/src/hooks/use-repository-picker.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
import { useAction, useMutation, useQuery } from "convex/react";
|
||||||
import { makeFunctionReference } from "convex/server";
|
import { makeFunctionReference } from "convex/server";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||||
@@ -12,13 +13,6 @@ import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
|||||||
const toError = (error: unknown) =>
|
const toError = (error: unknown) =>
|
||||||
error instanceof Error ? error : new Error(String(error));
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
type ExecutionScenario =
|
|
||||||
| "success"
|
|
||||||
| "transient-failure-then-success"
|
|
||||||
| "needs-input"
|
|
||||||
| "permanent-failure"
|
|
||||||
| "cancelled";
|
|
||||||
|
|
||||||
const workListRef = makeFunctionReference<
|
const workListRef = makeFunctionReference<
|
||||||
"query",
|
"query",
|
||||||
{ projectId: Id<"projects"> },
|
{ projectId: Id<"projects"> },
|
||||||
@@ -34,46 +28,26 @@ const saveDefinitionRef = makeFunctionReference<
|
|||||||
{ workId: Id<"works">; payloadJson: string },
|
{ workId: Id<"works">; payloadJson: string },
|
||||||
unknown
|
unknown
|
||||||
>("workPlanning:saveDefinitionProposal");
|
>("workPlanning:saveDefinitionProposal");
|
||||||
const approveDefinitionRef = makeFunctionReference<
|
|
||||||
"mutation",
|
|
||||||
{ workId: Id<"works">; version: number },
|
|
||||||
unknown
|
|
||||||
>("workPlanning:approveDefinition");
|
|
||||||
const saveDesignRef = makeFunctionReference<
|
const saveDesignRef = makeFunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
{ workId: Id<"works">; payloadJson: string },
|
{ workId: Id<"works">; payloadJson: string },
|
||||||
unknown
|
unknown
|
||||||
>("workPlanning:saveDesignProposal");
|
>("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<
|
const startExecutionRef = makeFunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
{ workId: Id<"works">; sliceId?: string },
|
{ sliceId?: string; workId: Id<"works"> },
|
||||||
unknown
|
unknown
|
||||||
>("workExecutionWorkflow:startExecution");
|
>("workExecution:start");
|
||||||
const cancelExecutionRef = makeFunctionReference<
|
const cancelExecutionRef = makeFunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
{ runId: Id<"workRuns"> },
|
{ runId: Id<"workRuns"> },
|
||||||
unknown
|
unknown
|
||||||
>("workExecutionWorkflow:cancelExecution");
|
>("workExecution:cancel");
|
||||||
|
const retryExecutionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ runId: Id<"workRuns"> },
|
||||||
|
unknown
|
||||||
|
>("workExecution:retry");
|
||||||
const listGitConnectionsRef = makeFunctionReference<
|
const listGitConnectionsRef = makeFunctionReference<
|
||||||
"query",
|
"query",
|
||||||
Record<string, never>,
|
Record<string, never>,
|
||||||
@@ -96,7 +70,7 @@ const projectGitConnectionRef = makeFunctionReference<
|
|||||||
>("gitConnectionData:getForProject");
|
>("gitConnectionData:getForProject");
|
||||||
const connectGiteaRef = makeFunctionReference<
|
const connectGiteaRef = makeFunctionReference<
|
||||||
"action",
|
"action",
|
||||||
{ serverUrl: string; token: string; username?: string },
|
{ token: string; username?: string },
|
||||||
{ connectionId: Id<"gitConnections"> }
|
{ connectionId: Id<"gitConnections"> }
|
||||||
>("gitConnections:connectGitea");
|
>("gitConnections:connectGitea");
|
||||||
const connectGithubRef = makeFunctionReference<
|
const connectGithubRef = makeFunctionReference<
|
||||||
@@ -118,6 +92,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
);
|
);
|
||||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||||
const agent = useOrganizationChatAgent(organization);
|
const agent = useOrganizationChatAgent(organization);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [selectedProjectId, setSelectedProjectId] =
|
const [selectedProjectId, setSelectedProjectId] =
|
||||||
useState<Id<"projects"> | null>(null);
|
useState<Id<"projects"> | null>(null);
|
||||||
const [repository, setRepository] = useState("");
|
const [repository, setRepository] = useState("");
|
||||||
@@ -125,11 +100,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [operationError, setOperationError] = 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(
|
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
|
const activeProjectId = selectedProjectStillExists
|
||||||
? selectedProjectId
|
? (selectedProjectId ?? paramProject)
|
||||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||||
const works = useQuery(
|
const works = useQuery(
|
||||||
workListRef,
|
workListRef,
|
||||||
@@ -145,14 +126,10 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
);
|
);
|
||||||
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
||||||
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
||||||
const approveDefinitionMutation = useMutation(approveDefinitionRef);
|
|
||||||
const saveDesignMutation = useMutation(saveDesignRef);
|
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 startExecutionMutation = useMutation(startExecutionRef);
|
||||||
const cancelExecutionMutation = useMutation(cancelExecutionRef);
|
const cancelExecutionMutation = useMutation(cancelExecutionRef);
|
||||||
|
const retryExecutionMutation = useMutation(retryExecutionRef);
|
||||||
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
|
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
|
||||||
const connectGiteaAction = useAction(connectGiteaRef);
|
const connectGiteaAction = useAction(connectGiteaRef);
|
||||||
const connectGithubAction = useAction(connectGithubRef);
|
const connectGithubAction = useAction(connectGithubRef);
|
||||||
@@ -202,11 +179,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectGitea = (input: {
|
const connectGitea = (input: { token: string; username?: string }) =>
|
||||||
serverUrl: string;
|
|
||||||
token: string;
|
|
||||||
username?: string;
|
|
||||||
}) =>
|
|
||||||
runOperation(async () => {
|
runOperation(async () => {
|
||||||
const result = await connectGiteaAction(input);
|
const result = await connectGiteaAction(input);
|
||||||
await attachConnection(result.connectionId);
|
await attachConnection(result.connectionId);
|
||||||
@@ -220,22 +193,13 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
agent,
|
agent,
|
||||||
approveDefinition: (workId: Id<"works">, version: number) =>
|
|
||||||
approveDefinitionMutation({ version, workId }),
|
|
||||||
approveDesign: (
|
|
||||||
workId: Id<"works">,
|
|
||||||
definitionVersion: number,
|
|
||||||
designVersion: number
|
|
||||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
|
||||||
authorizeGithub: () =>
|
authorizeGithub: () =>
|
||||||
authClient.signIn.social({
|
authClient.linkSocial({
|
||||||
callbackURL: window.location.href,
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
provider: "github",
|
provider: "github",
|
||||||
}),
|
}),
|
||||||
cancelExecution: (runId: Id<"workRuns">) =>
|
cancelExecution: (runId: Id<"workRuns">) =>
|
||||||
runOperation(() => cancelExecutionMutation({ runId })),
|
runOperation(() => cancelExecutionMutation({ runId })),
|
||||||
cancelSimulation: (runId: Id<"workRuns">) =>
|
|
||||||
runOperation(() => cancelSimulationMutation({ runId })),
|
|
||||||
clearOperationError: () => setOperationError(undefined),
|
clearOperationError: () => setOperationError(undefined),
|
||||||
connectGitea,
|
connectGitea,
|
||||||
connectLinkedGithub,
|
connectLinkedGithub,
|
||||||
@@ -249,8 +213,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
repository,
|
repository,
|
||||||
requestDefinition: (workId: Id<"works">) =>
|
requestDefinition: (workId: Id<"works">) =>
|
||||||
requestDefinitionMutation({ workId }),
|
requestDefinitionMutation({ workId }),
|
||||||
retrySimulation: (runId: Id<"workRuns">) =>
|
retryExecution: (runId: Id<"workRuns">) =>
|
||||||
runOperation(() => retrySimulationMutation({ runId })),
|
runOperation(() => retryExecutionMutation({ runId })),
|
||||||
saveDefinition: (workId: Id<"works">, payload: unknown) =>
|
saveDefinition: (workId: Id<"works">, payload: unknown) =>
|
||||||
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
|
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||||
saveDesign: (workId: Id<"works">, payload: unknown) =>
|
saveDesign: (workId: Id<"works">, payload: unknown) =>
|
||||||
@@ -263,14 +227,6 @@ export const useProjectWorkspace = (): WorkspaceState => {
|
|||||||
setRepository,
|
setRepository,
|
||||||
startExecution: (workId: Id<"works">, sliceId?: string) =>
|
startExecution: (workId: Id<"works">, sliceId?: string) =>
|
||||||
runOperation(() => startExecutionMutation({ sliceId, workId })),
|
runOperation(() => startExecutionMutation({ sliceId, workId })),
|
||||||
startSimulation: (
|
|
||||||
workId: Id<"works">,
|
|
||||||
scenario: ExecutionScenario,
|
|
||||||
sliceId?: string
|
|
||||||
) =>
|
|
||||||
runOperation(() =>
|
|
||||||
startSimulationMutation({ scenario, sliceId, workId })
|
|
||||||
),
|
|
||||||
works,
|
works,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,4 +56,39 @@ describe("projectConversation", () => {
|
|||||||
{ state: "done", text: "Captured the request.", type: "text" },
|
{ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,18 @@ export interface ConversationRow {
|
|||||||
| "running";
|
| "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[]) => {
|
export const projectConversation = (rows: readonly ConversationRow[]) => {
|
||||||
|
const activeAssistant = latestAssistant(rows);
|
||||||
const streaming = rows.some(
|
const streaming = rows.some(
|
||||||
(row) =>
|
(row) =>
|
||||||
row.role === "assistant" &&
|
row.role === "assistant" &&
|
||||||
@@ -28,9 +39,7 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
|
|||||||
row.rawText.length > 0
|
row.rawText.length > 0
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
failedError: rows.findLast(
|
failedError: latestTerminalError(activeAssistant),
|
||||||
(row) => row.role === "assistant" && row.status === "failed"
|
|
||||||
)?.error,
|
|
||||||
messages: rows
|
messages: rows
|
||||||
.filter(
|
.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
@@ -65,13 +74,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => {
|
|||||||
],
|
],
|
||||||
role: row.role,
|
role: row.role,
|
||||||
})),
|
})),
|
||||||
pending: rows.some(
|
pending:
|
||||||
(row) =>
|
activeAssistant?.status === "queued" ||
|
||||||
row.role === "assistant" &&
|
activeAssistant?.status === "dispatching" ||
|
||||||
(row.status === "queued" ||
|
activeAssistant?.status === "running",
|
||||||
row.status === "dispatching" ||
|
|
||||||
row.status === "running")
|
|
||||||
),
|
|
||||||
streaming,
|
streaming,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface WorkRecord {
|
|||||||
readonly objective: string;
|
readonly objective: string;
|
||||||
readonly status: string;
|
readonly status: string;
|
||||||
readonly definitionVersion?: number;
|
readonly definitionVersion?: number;
|
||||||
readonly definitionApprovalVersion?: number;
|
|
||||||
readonly designVersion?: number;
|
readonly designVersion?: number;
|
||||||
readonly signals: readonly {
|
readonly signals: readonly {
|
||||||
readonly sources: readonly { messageId: string; rawText: string }[];
|
readonly sources: readonly { messageId: string; rawText: string }[];
|
||||||
@@ -34,7 +33,6 @@ export interface WorkRun {
|
|||||||
readonly attemptEvents?: readonly WorkAttemptEvent[];
|
readonly attemptEvents?: readonly WorkAttemptEvent[];
|
||||||
readonly baseRevision?: string;
|
readonly baseRevision?: string;
|
||||||
readonly candidateRevision?: string;
|
readonly candidateRevision?: string;
|
||||||
readonly executionKind?: string;
|
|
||||||
readonly _id: Id<"workRuns">;
|
readonly _id: Id<"workRuns">;
|
||||||
readonly status: string;
|
readonly status: string;
|
||||||
readonly terminalClassification?: string;
|
readonly terminalClassification?: string;
|
||||||
@@ -81,21 +79,10 @@ export interface WorkspaceState {
|
|||||||
| "streaming"
|
| "streaming"
|
||||||
| "submitted";
|
| "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 authorizeGithub: () => Promise<unknown>;
|
||||||
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
|
||||||
readonly clearOperationError: () => void;
|
readonly clearOperationError: () => void;
|
||||||
readonly connectGitea: (input: {
|
readonly connectGitea: (input: {
|
||||||
readonly serverUrl: string;
|
|
||||||
readonly token: string;
|
readonly token: string;
|
||||||
readonly username?: string;
|
readonly username?: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
@@ -109,7 +96,7 @@ export interface WorkspaceState {
|
|||||||
readonly projects: readonly ProjectListItem[] | undefined;
|
readonly projects: readonly ProjectListItem[] | undefined;
|
||||||
readonly repository: string;
|
readonly repository: string;
|
||||||
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
|
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
|
||||||
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
readonly retryExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||||
readonly saveDefinition: (
|
readonly saveDefinition: (
|
||||||
workId: Id<"works">,
|
workId: Id<"works">,
|
||||||
payload: unknown
|
payload: unknown
|
||||||
@@ -125,16 +112,6 @@ export interface WorkspaceState {
|
|||||||
workId: Id<"works">,
|
workId: Id<"works">,
|
||||||
sliceId?: string
|
sliceId?: string
|
||||||
) => Promise<unknown>;
|
) => 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;
|
readonly works: readonly WorkRecord[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ export default [
|
|||||||
route("login", "./routes/auth/login/page.tsx"),
|
route("login", "./routes/auth/login/page.tsx"),
|
||||||
route("signup", "./routes/auth/signup/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;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -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 { requireAuthToken } from "@/lib/auth.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/layout";
|
import type { Route } from "./+types/layout";
|
||||||
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
|
|||||||
requireAuthToken(request);
|
requireAuthToken(request);
|
||||||
|
|
||||||
export default function AppLayout() {
|
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 />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
125
apps/web/src/routes/app/projects/page.tsx
Normal file
125
apps/web/src/routes/app/projects/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite-plus";
|
import { defineConfig, loadEnv } from "vite-plus";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
const envDir = path.resolve(import.meta.dirname, "../..");
|
||||||
|
const env = loadEnv(mode, envDir, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
envDir,
|
||||||
plugins: [tailwindcss(), reactRouter()],
|
plugins: [tailwindcss(), reactRouter()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["convex", "react", "react-dom"],
|
dedupe: ["convex", "react", "react-dom"],
|
||||||
@@ -16,8 +20,9 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
"/api/auth": {
|
"/api/auth": {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
target: "https://befitting-dalmatian-161.convex.site",
|
target: env.CONVEX_SITE_URL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
63
deploy/compose/Caddyfile
Normal file
63
deploy/compose/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
55
deploy/compose/docker-compose.yml
Normal file
55
deploy/compose/docker-compose.yml
Normal 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
|
||||||
7
deploy/compose/rivet-config.json
Normal file
7
deploy/compose/rivet-config.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://rivet.dev/engine-config-schema.json",
|
||||||
|
"file_system": {
|
||||||
|
"path": "/data"
|
||||||
|
},
|
||||||
|
"singleplayer": false
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM oven/bun:1.3.14 AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
RUN bun run --filter @code/agents build
|
|
||||||
|
|
||||||
FROM node:24-bookworm-slim
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=build /app /app
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "packages/agents/dist/server.mjs"]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: packages/agents/Dockerfile
|
|
||||||
environment:
|
|
||||||
AGENT_BACKEND_URL: ${AGENT_BACKEND_URL}
|
|
||||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
|
||||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
|
||||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
|
||||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
|
||||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
|
||||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
|
||||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
|
||||||
CONVEX_URL: ${CONVEX_URL}
|
|
||||||
DAEMON_ID: zopu-agent-backend
|
|
||||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
|
||||||
GITEA_URL: ${GITEA_URL:-https://git.openputer.com}
|
|
||||||
PORT: "3000"
|
|
||||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
|
||||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
|
||||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- dokploy-network
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
|
||||||
dokploy-network:
|
|
||||||
external: true
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
services:
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: apps/web/Dockerfile
|
|
||||||
args:
|
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL}
|
|
||||||
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
|
|
||||||
environment:
|
|
||||||
HOST: 0.0.0.0
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: "3000"
|
|
||||||
VITE_AUTH_URL: ${VITE_AUTH_URL}
|
|
||||||
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
FROM oven/bun:1.3.14 AS bun
|
|
||||||
|
|
||||||
FROM node:24-bookworm-slim
|
|
||||||
|
|
||||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /opt/zopu-source
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
|
|
||||||
ENV BUN_EXECUTABLE=/usr/local/bin/bun
|
|
||||||
ENV DAEMON_ID=zopu-agentos-runner
|
|
||||||
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
|
||||||
|
|
||||||
VOLUME ["/var/lib/zopu/workspaces"]
|
|
||||||
|
|
||||||
CMD ["bun", "packages/agents/src/runner.ts"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
services:
|
|
||||||
runner:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: deploy/dokploy/runner.Dockerfile
|
|
||||||
environment:
|
|
||||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
|
||||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
|
||||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
|
||||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
|
||||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
|
||||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
|
||||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
|
||||||
CONVEX_URL: ${CONVEX_URL}
|
|
||||||
DAEMON_ID: zopu-agentos-runner
|
|
||||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
|
||||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
|
||||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
|
||||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
|
||||||
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
|
|
||||||
volumes:
|
|
||||||
- runner-workspaces:/var/lib/zopu/workspaces
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
runner-workspaces:
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
FROM oven/bun:1.3.14 AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
RUN bun run --filter web build
|
|
||||||
|
|
||||||
FROM node:24-bookworm-slim
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=build /app /app
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "apps/web/node_modules/.bin/react-router-serve", "apps/web/build/server/index.js"]
|
|
||||||
17
deploy/systemd/Caddyfile
Normal file
17
deploy/systemd/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
23
deploy/systemd/zopu-agents.service
Normal file
23
deploy/systemd/zopu-agents.service
Normal 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
|
||||||
4
deploy/systemd/zopu-caddy.service.conf
Normal file
4
deploy/systemd/zopu-caddy.service.conf
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/zopu/agents.env
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
###############################################################################
|
|
||||||
# Zopu Runtime Deployment — Environment Template
|
|
||||||
#
|
|
||||||
# Copy to .env and fill in real values. Never commit .env to the repository.
|
|
||||||
# This file documents every environment group required by the single-node
|
|
||||||
# execution plane. Lines marked REQUIRED must be set before first start.
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Convex (control plane) — REQUIRED
|
|
||||||
# Public URLs for the self-hosted Convex deployment.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CONVEX_URL=https://your-deployment.convex.cloud
|
|
||||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
|
||||||
SITE_URL=http://localhost:13100
|
|
||||||
VITE_AUTH_URL=http://localhost:13100
|
|
||||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
|
||||||
VITE_FLUE_URL=http://localhost:3583
|
|
||||||
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
|
||||||
|
|
||||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
|
||||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
|
||||||
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
|
|
||||||
CONVEX_INSTANCE_NAME=zopu-production
|
|
||||||
CONVEX_INSTANCE_SECRET=
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Model gateway — REQUIRED
|
|
||||||
# All model calls route through this OpenAI-compatible endpoint.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AGENT_MODEL_PROVIDER=cheaptricks
|
|
||||||
AGENT_MODEL_NAME=glm-5.2
|
|
||||||
AGENT_MODEL_API=openai-completions
|
|
||||||
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
|
|
||||||
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
|
|
||||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
|
||||||
AGENT_MODEL_MAX_TOKENS=131072
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. AgentOS / Rivet Engine — REQUIRED for the execution runner
|
|
||||||
# The runner creates isolated worktrees from ZOPU_SOURCE_REPOSITORY and
|
|
||||||
# connects to AgentOS through the public engine endpoint.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
RIVET_ENDPOINT=https://default:@rivet.example.com
|
|
||||||
RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com
|
|
||||||
RIVET_RUNNER_VERSION=1
|
|
||||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
|
||||||
ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Zopu agent service (Flue)
|
|
||||||
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
|
|
||||||
# zopu-agent.service pins the Flue Node server to port 3583.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FLUE_DB_TOKEN=replace-with-long-random-token
|
|
||||||
AGENT_BACKEND_URL=https://zopu-agent.example.com
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Daemon identity
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DAEMON_ID=zopu-dedicated
|
|
||||||
DAEMON_NAME=Zopu-Dedicated-Server
|
|
||||||
DAEMON_VERSION=0.0.0
|
|
||||||
DAEMON_HEARTBEAT_MS=15000
|
|
||||||
DAEMON_COMMAND_LEASE_MS=60000
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Docker sandbox
|
|
||||||
# The zopu service user is added to the docker group during bootstrap.
|
|
||||||
# Orb sandboxes will use Docker for full-system isolation, but the Orb
|
|
||||||
# lane contract has not landed yet. Docker access is provisioned now so
|
|
||||||
# the boundary is ready; no Docker-backed sandbox code is wired today.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# No env vars required; Docker socket access is via group membership.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Service authentication / secrets
|
|
||||||
# These tokens authenticate inter-service calls. Generate strong randoms.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
|
|
||||||
#AUTH_SECRET=replace-with-64-char-hex
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Private networking (Tailscale) — OPTIONAL
|
|
||||||
# When Tailscale is available, set the hostname for private DNS.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#TAILSCALE_HOSTNAME=zopu-runtime
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# Zopu Single-Node Runtime Deployment
|
|
||||||
|
|
||||||
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Debian Dedicated Server │
|
|
||||||
│ ~12 CPU cores · ~40 GB RAM · single-node │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
|
|
||||||
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
|
|
||||||
│ │ :13100 │ │ server.mjs │ │ backend │ │
|
|
||||||
│ │ │ │ :3583 │ │ :3210/:3211 │ │
|
|
||||||
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌──────▼───────┐ │
|
|
||||||
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ systemd timers: health-check (60s), docker-cleanup (daily) │
|
|
||||||
│ cron: disk-monitor (daily 06:00) │
|
|
||||||
│ ufw: deny-incoming, SSH + tailscale0 │
|
|
||||||
│ Tailscale: optional private overlay │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Single-node RivetKit topology
|
|
||||||
|
|
||||||
The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
|
|
||||||
|
|
||||||
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()` → `runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
|
|
||||||
|
|
||||||
**No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
|
|
||||||
|
|
||||||
### Docker / Orb boundary
|
|
||||||
|
|
||||||
Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
```
|
|
||||||
deploy/zopu-runtime/
|
|
||||||
├── bootstrap.sh # One-shot Debian installer
|
|
||||||
├── .env.template # Environment template (all groups documented)
|
|
||||||
├── README.md # This file (runbook)
|
|
||||||
├── systemd/
|
|
||||||
│ ├── zopu-daemon.service # Daemon systemd unit
|
|
||||||
│ ├── zopu-agent.service # Agent systemd unit
|
|
||||||
│ ├── zopu-web.service # React Router web systemd unit
|
|
||||||
│ ├── zopu-health.service # Health check oneshot
|
|
||||||
│ ├── zopu-health.timer # Health check every 60s
|
|
||||||
│ ├── zopu-docker-cleanup.service
|
|
||||||
│ └── zopu-docker-cleanup.timer
|
|
||||||
├── scripts/
|
|
||||||
│ ├── health-check.sh # TCP/process health probes
|
|
||||||
│ ├── update.sh # Update to branch or commit
|
|
||||||
│ ├── rollback.sh # Roll back to previous commit
|
|
||||||
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
|
|
||||||
│ └── disk-monitor.sh # Disk usage alerting
|
|
||||||
└── caddy/
|
|
||||||
└── Caddyfile # Optional reverse proxy config (documentation)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fresh install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. SSH into the fresh Debian 12 server as root.
|
|
||||||
|
|
||||||
# 2. Set environment overrides (optional):
|
|
||||||
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
|
|
||||||
export ZOPU_REPO_BRANCH="dogfood/v0"
|
|
||||||
# export TAILSCALE_AUTHKEY="tskey-..."
|
|
||||||
# export TAILSCALE_HOSTNAME="zopu-runtime"
|
|
||||||
|
|
||||||
# 3. Run the bootstrap script:
|
|
||||||
bash bootstrap.sh
|
|
||||||
|
|
||||||
# 4. Edit .env with real values:
|
|
||||||
nano /opt/zopu/.env
|
|
||||||
|
|
||||||
# 5. Start services:
|
|
||||||
systemctl start zopu-web zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl start zopu-agent
|
|
||||||
|
|
||||||
# 6. Enable timers:
|
|
||||||
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
|
|
||||||
|
|
||||||
# 7. Verify:
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start / stop / restart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start all services
|
|
||||||
systemctl start zopu-web zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
systemctl stop zopu-agent zopu-daemon zopu-web
|
|
||||||
|
|
||||||
# Restart (daemon first — it owns the RivetKit engine)
|
|
||||||
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
|
|
||||||
|
|
||||||
# Enable on boot
|
|
||||||
systemctl enable zopu-web zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Disable on boot
|
|
||||||
systemctl disable zopu-web zopu-daemon zopu-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Log inspection
|
|
||||||
|
|
||||||
All service logs go to journald with `SyslogIdentifier` tags.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Daemon logs (live follow)
|
|
||||||
journalctl -u zopu-daemon -f
|
|
||||||
|
|
||||||
# Agent logs (live follow)
|
|
||||||
journalctl -u zopu-agent -f
|
|
||||||
|
|
||||||
# Last 100 lines of daemon
|
|
||||||
journalctl -u zopu-daemon -n 100
|
|
||||||
|
|
||||||
# Logs since boot
|
|
||||||
journalctl -u zopu-daemon -b
|
|
||||||
|
|
||||||
# Health check timer logs
|
|
||||||
journalctl -u zopu-health.service -n 50
|
|
||||||
|
|
||||||
# Docker cleanup logs
|
|
||||||
journalctl -u zopu-docker-cleanup.service -n 50
|
|
||||||
|
|
||||||
# Disk monitor logs (cron → file)
|
|
||||||
tail -100 /var/log/zopu/disk-monitor.log
|
|
||||||
|
|
||||||
# All Zopu syslog identifiers
|
|
||||||
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual health check (prints all probes)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
|
|
||||||
# Quiet mode (exit code only)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
||||||
|
|
||||||
# Check systemd timer is running
|
|
||||||
systemctl status zopu-health.timer
|
|
||||||
systemctl list-timers zopu-health.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
The health check probes:
|
|
||||||
|
|
||||||
1. `zopu-daemon` systemd unit is active
|
|
||||||
2. `zopu-agent` systemd unit is active
|
|
||||||
3. RivetKit engine port (default 6420) accepts TCP connections
|
|
||||||
4. Flue agent port (default 3583) accepts TCP connections
|
|
||||||
5. Docker daemon responds to `docker info`
|
|
||||||
|
|
||||||
No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
|
|
||||||
|
|
||||||
## Update to commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update to latest of dogfood/v0 (default)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
|
|
||||||
|
|
||||||
# Update to a specific branch
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
|
|
||||||
|
|
||||||
# Update to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
The update script:
|
|
||||||
|
|
||||||
1. Records current HEAD to `.last-deployed-sha`
|
|
||||||
2. Fetches, resolves branch-or-commit, checks out
|
|
||||||
3. `bun install`, builds daemon and agent
|
|
||||||
4. Restarts daemon, waits, restarts agent
|
|
||||||
5. Runs health check; reports failure and rollback instructions
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Roll back to the previously deployed commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
|
|
||||||
|
|
||||||
# Roll back to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
|
|
||||||
|
|
||||||
## Docker cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual cleanup
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
||||||
|
|
||||||
# Check Docker disk usage
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
# Timer runs daily; check its schedule
|
|
||||||
systemctl list-timers zopu-docker-cleanup.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Cleanup prunes:
|
|
||||||
|
|
||||||
- Stopped containers older than 24 hours
|
|
||||||
- Dangling (untagged) images
|
|
||||||
- Unused networks
|
|
||||||
|
|
||||||
Named volumes and running containers are never removed.
|
|
||||||
|
|
||||||
## Disk-space monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual check
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
|
|
||||||
|
|
||||||
# Custom threshold (90%)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
|
|
||||||
```
|
|
||||||
|
|
||||||
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
|
|
||||||
|
|
||||||
## Firewall and private networking
|
|
||||||
|
|
||||||
The firewall (`ufw`) is deny-by-default:
|
|
||||||
|
|
||||||
- SSH (port 22) is allowed on all interfaces
|
|
||||||
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
|
|
||||||
- All other incoming traffic is denied
|
|
||||||
|
|
||||||
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
|
|
||||||
|
|
||||||
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
|
|
||||||
2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
|
|
||||||
```bash
|
|
||||||
ufw allow in on eth1 # or your private interface name
|
|
||||||
```
|
|
||||||
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
|
|
||||||
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
|
|
||||||
|
|
||||||
## Environment groups
|
|
||||||
|
|
||||||
See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
|
|
||||||
|
|
||||||
| Group | Variables |
|
|
||||||
| --- | --- |
|
|
||||||
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
|
|
||||||
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
|
|
||||||
| Model gateway | `AGENT_MODEL_*` |
|
|
||||||
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
|
|
||||||
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
|
|
||||||
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
|
|
||||||
| Docker sandbox | group membership (no env vars) |
|
|
||||||
| Service auth | `AUTH_SECRET` (if needed) |
|
|
||||||
|
|
||||||
## Public single-node routes
|
|
||||||
|
|
||||||
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
|
|
||||||
|
|
||||||
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
|
|
||||||
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
|
|
||||||
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
|
|
||||||
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
|
|
||||||
|
|
||||||
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose \
|
|
||||||
--env-file /opt/zopu/.env \
|
|
||||||
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
|
|
||||||
exec backend ./generate_admin_key.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## What is NOT deployed
|
|
||||||
|
|
||||||
- **Kubernetes**: no container orchestration.
|
|
||||||
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
|
|
||||||
- **Multi-node coordination**: single-node only.
|
|
||||||
- **Public administration endpoints**: no admin HTTP surface.
|
|
||||||
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
|
|
||||||
- **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
|
|
||||||
|
|
||||||
1. Services run under systemd with `Restart=always`.
|
|
||||||
2. Logs persist in journald.
|
|
||||||
3. Health checks run every 60 seconds via systemd timer.
|
|
||||||
4. Docker cleanup runs daily.
|
|
||||||
5. Disk usage is monitored daily.
|
|
||||||
6. Unattended-upgrades handles Debian security patches.
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
|
|
||||||
#
|
|
||||||
# Run as root on a fresh Debian 12 host:
|
|
||||||
#
|
|
||||||
# bash bootstrap.sh
|
|
||||||
#
|
|
||||||
# Environment overrides (set before running):
|
|
||||||
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
|
|
||||||
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
|
|
||||||
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
|
|
||||||
# ZOPU_SERVICE_USER — system user (default: zopu)
|
|
||||||
# TAILSCALE_AUTHKEY — if set, configure Tailscale
|
|
||||||
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
|
|
||||||
#
|
|
||||||
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
|
|
||||||
# web app, daemon, and agent, creates a non-root service user, installs systemd
|
|
||||||
# units, and configures firewall/Tailscale defaults.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
|
|
||||||
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
# runuser is part of util-linux (essential on Debian) and always available.
|
|
||||||
# sudo is NOT assumed on minimal Debian installs.
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pre-flight
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ "$EUID" -ne 0 ]]; then
|
|
||||||
err "This script must be run as root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f /etc/debian_version ]]; then
|
|
||||||
log "Detected Debian $(cat /etc/debian_version)"
|
|
||||||
else
|
|
||||||
warn "This script targets Debian 12. Other distributions may need manual adjustments."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. System packages
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Updating apt and installing base packages..."
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gnupg \
|
|
||||||
ufw \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
netcat-openbsd \
|
|
||||||
openssh-client \
|
|
||||||
unattended-upgrades \
|
|
||||||
rsyslog
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Docker Engine
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v docker &>/dev/null; then
|
|
||||||
log "Installing Docker Engine..."
|
|
||||||
install -m 0755 -d /etc/apt/keyrings
|
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
|
||||||
-o /etc/apt/keyrings/docker.asc
|
|
||||||
chmod a+r /etc/apt/keyrings/docker.asc
|
|
||||||
|
|
||||||
echo \
|
|
||||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
|
||||||
https://download.docker.com/linux/debian \
|
|
||||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
|
||||||
> /etc/apt/sources.list.d/docker.list
|
|
||||||
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
docker-ce \
|
|
||||||
docker-ce-cli \
|
|
||||||
containerd.io \
|
|
||||||
docker-buildx-plugin \
|
|
||||||
docker-compose-plugin
|
|
||||||
else
|
|
||||||
log "Docker Engine already installed: $(docker --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl enable --now docker
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Node.js 22 (required by the Flue Node target)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
|
|
||||||
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
|
|
||||||
log "Installing Node.js 22..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
|
|
||||||
bash /tmp/nodesource_setup.sh
|
|
||||||
apt-get install -y nodejs
|
|
||||||
else
|
|
||||||
log "Node.js already installed: $(node --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Bun
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v bun &>/dev/null; then
|
|
||||||
log "Installing Bun..."
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
else
|
|
||||||
log "Bun already installed: $(bun --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
log "Creating service user: $SERVICE_USER"
|
|
||||||
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
|
|
||||||
usermod -aG docker "$SERVICE_USER"
|
|
||||||
log "Added $SERVICE_USER to docker group"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Clone or update repository
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
log "Repository exists at $INSTALL_DIR, fetching latest..."
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
git fetch origin
|
|
||||||
git checkout "$REPO_BRANCH"
|
|
||||||
git reset --hard "origin/$REPO_BRANCH"
|
|
||||||
else
|
|
||||||
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
|
|
||||||
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6b. Hand ownership of the checkout to the service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Install dependencies and build
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Environment file
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="$INSTALL_DIR/.env"
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
|
|
||||||
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
warn "Edit $ENV_FILE with real values before starting services."
|
|
||||||
else
|
|
||||||
log ".env already exists at $ENV_FILE"
|
|
||||||
# Ensure correct ownership and permissions on existing .env
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Persistent log directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LOG_DIR="/var/log/zopu"
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 10. Install systemd units (substitute placeholders)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing systemd units..."
|
|
||||||
for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
|
|
||||||
zopu-health.timer zopu-health.service \
|
|
||||||
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
|
|
||||||
SRC="$DEPLOY_DIR/systemd/$unit"
|
|
||||||
DST="/etc/systemd/system/$unit"
|
|
||||||
if [[ -f "$SRC" ]]; then
|
|
||||||
sed \
|
|
||||||
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
|
|
||||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
|
||||||
"$SRC" > "$DST"
|
|
||||||
log " installed $unit"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Configuring firewall..."
|
|
||||||
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
|
|
||||||
ufw allow 22/tcp
|
|
||||||
ufw default deny incoming
|
|
||||||
ufw default allow outgoing
|
|
||||||
|
|
||||||
# Allow all traffic on the Tailscale interface (if present)
|
|
||||||
# This lets the agent and engine ports be reached over the private overlay.
|
|
||||||
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
|
|
||||||
|
|
||||||
ufw --force enable
|
|
||||||
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
|
|
||||||
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
|
|
||||||
warn "Reachability is via Tailscale (tailscale0) only."
|
|
||||||
else
|
|
||||||
log "Firewall already active. Ensuring tailscale0 rule..."
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 12. Tailscale (optional)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
|
|
||||||
log "Installing and configuring Tailscale..."
|
|
||||||
if ! command -v tailscaled &>/dev/null; then
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
fi
|
|
||||||
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
|
|
||||||
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
|
|
||||||
--accept-routes
|
|
||||||
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
|
|
||||||
|
|
||||||
# Re-apply the tailscale0 firewall rule now that the interface exists
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
else
|
|
||||||
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
|
|
||||||
warn "Without Tailscale, services are reachable only via localhost."
|
|
||||||
warn "To use a private network interface, add an explicit UFW rule:"
|
|
||||||
warn " ufw allow in on <interface>"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 13. Disk-space monitoring cron
|
|
||||||
# /etc/cron.d format REQUIRES a username field.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing disk-space monitor (daily at 06:00)..."
|
|
||||||
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
|
|
||||||
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
|
|
||||||
chmod 644 /etc/cron.d/zopu-disk-monitor
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Done
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Bootstrap complete."
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. Edit $ENV_FILE with real values"
|
|
||||||
echo " 2. Start services:"
|
|
||||||
echo " systemctl start zopu-web zopu-daemon zopu-agent"
|
|
||||||
echo " 3. Enable health monitoring:"
|
|
||||||
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
|
|
||||||
echo " 4. Verify health:"
|
|
||||||
echo " $DEPLOY_DIR/scripts/health-check.sh"
|
|
||||||
echo ""
|
|
||||||
warn "Services are NOT started automatically. Edit .env first."
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
|
||||||
#
|
|
||||||
# The web frontend (port 5173) is served at the root, Better Auth is proxied
|
|
||||||
# first-party under /api/auth, and Flue is mounted under /api/flue.
|
|
||||||
|
|
||||||
zopu.cheaptricks.puter.wtf {
|
|
||||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
|
||||||
encode zstd gzip
|
|
||||||
|
|
||||||
handle /api/auth/* {
|
|
||||||
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
|
||||||
header_up Host befitting-dalmatian-161.convex.site
|
|
||||||
header_up X-Forwarded-Host {host}
|
|
||||||
header_up X-Forwarded-Proto {scheme}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_path /api/flue/* {
|
|
||||||
reverse_proxy 127.0.0.1:3585
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy 127.0.0.1:5173
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
admin 127.0.0.1:2019
|
|
||||||
auto_https off
|
|
||||||
http_port 8080
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:13100
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-api.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3210
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-site.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3211
|
|
||||||
}
|
|
||||||
|
|
||||||
http://zopu-agent.sai-onchain.me {
|
|
||||||
bind 127.0.0.1
|
|
||||||
reverse_proxy 127.0.0.1:3583
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
|
|
||||||
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
- hostname: zopu.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-api.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-site.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- hostname: zopu-agent.sai-onchain.me
|
|
||||||
service: http://127.0.0.1:8080
|
|
||||||
- service: http_status:404
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
image: ghcr.io/get-convex/convex-backend:latest
|
|
||||||
container_name: zopu-convex
|
|
||||||
restart: unless-stopped
|
|
||||||
stop_grace_period: 10s
|
|
||||||
stop_signal: SIGINT
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3210:3210"
|
|
||||||
- "127.0.0.1:3211:3211"
|
|
||||||
volumes:
|
|
||||||
- zopu-convex-data:/convex/data
|
|
||||||
environment:
|
|
||||||
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
|
|
||||||
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
|
|
||||||
DISABLE_BEACON: "true"
|
|
||||||
DISABLE_METRICS_ENDPOINT: "true"
|
|
||||||
DOCUMENT_RETENTION_DELAY: "172800"
|
|
||||||
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
|
|
||||||
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
|
|
||||||
REDACT_LOGS_TO_CLIENT: "true"
|
|
||||||
RUST_LOG: ${CONVEX_RUST_LOG:-info}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
zopu-convex-data:
|
|
||||||
name: zopu-convex-data
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
FROM oven/bun:1.3.14 AS bun
|
|
||||||
|
|
||||||
FROM node:24-bookworm-slim
|
|
||||||
|
|
||||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /opt/zopu-source
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
|
|
||||||
ENV BUN_EXECUTABLE=/usr/local/bin/bun
|
|
||||||
ENV DAEMON_ID=zopu-agentos-runner
|
|
||||||
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
|
|
||||||
|
|
||||||
VOLUME ["/var/lib/zopu/workspaces"]
|
|
||||||
|
|
||||||
CMD ["bun", "packages/agents/src/runner.ts"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
services:
|
|
||||||
zopu-agentos-runner:
|
|
||||||
build:
|
|
||||||
context: ../../..
|
|
||||||
dockerfile: deploy/zopu-runtime/runner/Dockerfile
|
|
||||||
environment:
|
|
||||||
AGENT_MODEL_API: ${AGENT_MODEL_API}
|
|
||||||
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
|
|
||||||
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
|
|
||||||
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
|
|
||||||
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
|
|
||||||
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
|
|
||||||
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
|
|
||||||
CONVEX_URL: ${CONVEX_URL}
|
|
||||||
DAEMON_ID: zopu-agentos-runner
|
|
||||||
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
|
|
||||||
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
|
|
||||||
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
|
|
||||||
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
|
|
||||||
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
|
|
||||||
volumes:
|
|
||||||
- zopu-agentos-workspaces:/var/lib/zopu/workspaces
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
zopu-agentos-workspaces:
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# disk-monitor.sh — Check disk usage and alert when above threshold.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# disk-monitor.sh # print usage, warn at 80%
|
|
||||||
# disk-monitor.sh --warn-percent 90 # custom threshold
|
|
||||||
#
|
|
||||||
# Requires GNU coreutils df (standard on Debian). Uses --output for
|
|
||||||
# deterministic column ordering regardless of locale.
|
|
||||||
#
|
|
||||||
# Exit code 0 if under threshold, 1 if at or above.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
WARN_PERCENT=80
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--warn-percent)
|
|
||||||
WARN_PERCENT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown argument: $1" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
EXIT_CODE=0
|
|
||||||
|
|
||||||
# Partitions to check: install root and /var (Docker data-root is often here)
|
|
||||||
PARTITIONS="${PARTITIONS:-/ /var}"
|
|
||||||
|
|
||||||
for partition in $PARTITIONS; do
|
|
||||||
if [[ ! -d "$partition" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
|
|
||||||
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
|
|
||||||
|
|
||||||
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo " [skip] Could not read usage for ${partition}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
|
|
||||||
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
|
|
||||||
EXIT_CODE=1
|
|
||||||
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
|
|
||||||
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
|
|
||||||
else
|
|
||||||
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
exit "$EXIT_CODE"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
|
|
||||||
#
|
|
||||||
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
|
|
||||||
# commands with conservative scope.
|
|
||||||
#
|
|
||||||
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
|
|
||||||
# safely auto-pruned in a deployment that mixes stateful workloads. When the
|
|
||||||
# Orb lane lands with labeled resources, volume pruning can be scoped to
|
|
||||||
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
|
|
||||||
# manage volumes manually.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
# Remove stopped containers older than 24 hours
|
|
||||||
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
|
|
||||||
docker container prune -f --filter "until=24h"
|
|
||||||
|
|
||||||
# Remove dangling images (untagged intermediate layers only)
|
|
||||||
echo "[docker-cleanup] Pruning dangling images..."
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
# Remove unused networks
|
|
||||||
echo "[docker-cleanup] Pruning unused networks..."
|
|
||||||
docker network prune -f
|
|
||||||
|
|
||||||
# Volumes are intentionally NOT pruned. See header comment.
|
|
||||||
|
|
||||||
# Show remaining disk usage
|
|
||||||
echo "[docker-cleanup] Docker disk usage:"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
echo "[docker-cleanup] Done."
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# health-check.sh — Probe the Zopu execution plane.
|
|
||||||
#
|
|
||||||
# Checks (TCP/process-level; no assumed HTTP health routes):
|
|
||||||
# 1. zopu-daemon systemd unit is active
|
|
||||||
# 2. zopu-agent systemd unit is active
|
|
||||||
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
|
|
||||||
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
|
|
||||||
# 5. Docker daemon is reachable
|
|
||||||
#
|
|
||||||
# Flue does not expose a health endpoint by design. RivetKit's health route
|
|
||||||
# is internal to the registry runtime. We use TCP connection checks only.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# health-check.sh # print results
|
|
||||||
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
|
|
||||||
#
|
|
||||||
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
QUIET=false
|
|
||||||
[[ "${1:-}" == "--quiet" ]] && QUIET=true
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
|
|
||||||
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
|
|
||||||
|
|
||||||
FAILURES=0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Load environment
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
|
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
set -a
|
|
||||||
. "$ENV_FILE"
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
RIVET_PORT="6420"
|
|
||||||
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
|
|
||||||
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
|
|
||||||
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
|
|
||||||
fi
|
|
||||||
|
|
||||||
AGENT_PORT="${PORT:-3583}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
|
|
||||||
# Falls back to bash /dev/tcp if nc is unavailable.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
tcp_probe() {
|
|
||||||
local host="$1" port="$2"
|
|
||||||
if command -v nc &>/dev/null; then
|
|
||||||
nc -z -w 5 "$host" "$port" 2>/dev/null
|
|
||||||
else
|
|
||||||
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Daemon systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
|
|
||||||
pass "zopu-daemon service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-daemon service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Agent systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
|
|
||||||
pass "zopu-agent service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-agent service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. RivetKit engine TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$RIVET_PORT"; then
|
|
||||||
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Flue agent TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$AGENT_PORT"; then
|
|
||||||
pass "Flue agent port ${AGENT_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Docker daemon
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if docker info &>/dev/null; then
|
|
||||||
pass "Docker daemon is reachable"
|
|
||||||
else
|
|
||||||
fail "Docker daemon is not reachable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
$QUIET || echo ""
|
|
||||||
if [[ "$FAILURES" -eq 0 ]]; then
|
|
||||||
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# rollback.sh — Roll back to the previously deployed commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# rollback.sh # roll back to .last-deployed-sha
|
|
||||||
# rollback.sh <sha> # roll back to a specific commit
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Determine target
|
|
||||||
ROLLBACK_SHA="${1:-}"
|
|
||||||
if [[ -z "$ROLLBACK_SHA" ]]; then
|
|
||||||
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
if [[ ! -f "$LAST_SHA_FILE" ]]; then
|
|
||||||
err "No previous deployment recorded in ${LAST_SHA_FILE}."
|
|
||||||
err "Pass a commit SHA explicitly: rollback.sh <sha>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate commit exists
|
|
||||||
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
|
|
||||||
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current: ${CURRENT_SHA:0:12}"
|
|
||||||
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
|
|
||||||
|
|
||||||
# Save current state before rolling back (enables re-rollback)
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
|
|
||||||
|
|
||||||
# Checkout target
|
|
||||||
git checkout "$ROLLBACK_SHA"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
systemctl restart zopu-web
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Rollback complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after rollback!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
err " journalctl -u zopu-web -n 50"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# update.sh — Update the Zopu runtime to a specific branch or commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# update.sh # update to latest dogfood/v0
|
|
||||||
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
|
|
||||||
# update.sh <commit-sha> # checkout and build a specific commit
|
|
||||||
#
|
|
||||||
# The argument is treated as a branch name first; if no matching remote
|
|
||||||
# tracking branch exists it is treated as a commit SHA.
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
TARGET="${1:-dogfood/v0}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[update]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[update]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Record current commit for rollback
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current HEAD: ${CURRENT_SHA:0:12}"
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
|
|
||||||
# Fetch all remotes
|
|
||||||
log "Fetching from origin..."
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# Resolve target: branch first, then commit
|
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
|
|
||||||
log "Target is a branch: ${TARGET}"
|
|
||||||
git checkout -B "$TARGET" "origin/${TARGET}"
|
|
||||||
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
|
|
||||||
log "Target is a commit: ${TARGET:0:12}"
|
|
||||||
git checkout "$TARGET"
|
|
||||||
else
|
|
||||||
err "'${TARGET}' is not a known branch or valid commit."
|
|
||||||
err "Available branches: $(git branch -r | tr '\n' ' ')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NEW_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Now at: ${NEW_SHA:0:12}"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install and build
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building web app..."
|
|
||||||
run_as_service bun run --cwd apps/web build
|
|
||||||
|
|
||||||
log "Validating daemon production build..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
systemctl restart zopu-web
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Update complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after update!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
err " journalctl -u zopu-web -n 50"
|
|
||||||
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Update complete. Verify manually."
|
|
||||||
fi
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Agent Service (Flue Node server)
|
|
||||||
After=network-online.target zopu-daemon.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__/packages/agents
|
|
||||||
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=PORT=3583
|
|
||||||
|
|
||||||
# Flue's Node target requires Node built-ins such as node:sqlite.
|
|
||||||
# Listens on the service-pinned port 3583.
|
|
||||||
ExecStart=/usr/bin/node dist/server.mjs
|
|
||||||
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-agent
|
|
||||||
|
|
||||||
# Resource limits
|
|
||||||
MemoryMax=8G
|
|
||||||
CPUWeight=80
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Docker socket access for Orb sandbox runtime (lives in agent process)
|
|
||||||
SupplementaryGroups=docker
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Daemon (Bun/Effect + AgentOS/RivetKit)
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__
|
|
||||||
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=HOME=/var/lib/zopu
|
|
||||||
StateDirectory=zopu
|
|
||||||
|
|
||||||
# RivetKit in-process engine: envoy mode (serverful).
|
|
||||||
# Run the Bun source entrypoint so AgentOS software assets retain their real
|
|
||||||
# node_modules paths; Bun compiled executables do not embed .aospkg files.
|
|
||||||
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
|
|
||||||
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-daemon
|
|
||||||
|
|
||||||
# Resource limits (12-core, 40 GB host)
|
|
||||||
MemoryMax=8G
|
|
||||||
CPUWeight=100
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Docker socket access (for future Orb sandboxes; group membership required)
|
|
||||||
SupplementaryGroups=docker
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Docker Cleanup
|
|
||||||
After=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Docker Cleanup (daily)
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=daily
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Health Check
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
# health-check.sh reads ENV_FILE to find the .env to source. Set it to the
|
|
||||||
# install path so custom install dirs work correctly.
|
|
||||||
Environment=ENV_FILE=__INSTALL_DIR__/.env
|
|
||||||
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Health Check (every 60s)
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=30
|
|
||||||
OnUnitActiveSec=60
|
|
||||||
AccuracySec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Zopu Web
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__SERVICE_USER__
|
|
||||||
Group=__SERVICE_USER__
|
|
||||||
WorkingDirectory=__INSTALL_DIR__/apps/web
|
|
||||||
EnvironmentFile=__INSTALL_DIR__/.env
|
|
||||||
Environment=HOST=127.0.0.1
|
|
||||||
Environment=PORT=13100
|
|
||||||
ExecStart=/usr/local/bin/bun run start
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=zopu-web
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
168
docs/DEPLOYMENT_PLAN.md
Normal file
168
docs/DEPLOYMENT_PLAN.md
Normal 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 19–47).
|
||||||
|
|
||||||
|
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 environment’s Flue worker;
|
||||||
|
- `FLUE_DB_TOKEN` shared only with that environment’s 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. |
|
||||||
|
|
||||||
|
Rivet’s 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. Pulumi’s 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 Mac’s 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; Vite’s 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, 10–11; `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
333
docs/LOCAL_SETUP.md
Normal 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)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
> **Status:** Working source of truth
|
> **Status:** Working source of truth
|
||||||
> **Audience:** product, design, engineering, agents
|
> **Audience:** product, design, engineering, agents
|
||||||
> **Normative words:** **MUST** = required, **SHOULD** = default, **MAY** = optional
|
> **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
|
## 1. Thesis
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Technical founders and small engineering teams working in one Git repository.
|
|||||||
```text
|
```text
|
||||||
Project chat
|
Project chat
|
||||||
→ actionable Signal
|
→ actionable Signal
|
||||||
→ approved Work Definition
|
→ ready Work Definition
|
||||||
→ lightweight Design Packet
|
→ lightweight Design Packet
|
||||||
→ vertical slices
|
→ vertical slices
|
||||||
→ isolated coding run
|
→ 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:
|
All decisions requiring a human are first-class objects:
|
||||||
|
|
||||||
- clarify intent;
|
- clarify intent;
|
||||||
- approve definition/design;
|
- definition/design persist automatically;
|
||||||
- grant permission;
|
- grant permission;
|
||||||
- select an alternative;
|
- select an alternative;
|
||||||
- review a slice;
|
- review a slice;
|
||||||
@@ -250,7 +250,7 @@ Canonical knowledge updates require review.
|
|||||||
High-quality software is evaluated across:
|
High-quality software is evaluated across:
|
||||||
|
|
||||||
| Dimension | Required question |
|
| Dimension | Required question |
|
||||||
|---|---|
|
| ----------------- | -------------------------------------------------------- |
|
||||||
| Correctness | Does behavior satisfy the accepted criteria? |
|
| Correctness | Does behavior satisfy the accepted criteria? |
|
||||||
| Regression safety | Did existing behavior remain valid? |
|
| Regression safety | Did existing behavior remain valid? |
|
||||||
| Maintainability | Does the change preserve understandable boundaries? |
|
| Maintainability | Does the change preserve understandable boundaries? |
|
||||||
@@ -283,7 +283,7 @@ The implementation worker MUST NOT be the sole authority on completion.
|
|||||||
Builder → candidate output
|
Builder → candidate output
|
||||||
Verifier → independent evidence
|
Verifier → independent evidence
|
||||||
Resolver → completion decision
|
Resolver → completion decision
|
||||||
Human → policy-defined approvals
|
Human → merge/release authorization
|
||||||
```
|
```
|
||||||
|
|
||||||
### Risk lanes
|
### Risk lanes
|
||||||
@@ -309,7 +309,7 @@ definition → combined design → 1–3 slices → verify → review → PR
|
|||||||
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
|
Auth, billing, permissions, migrations, shared infrastructure, destructive operations.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
definition approval → architecture approval → program-design approval
|
definition → design → ready → merge/release
|
||||||
→ one slice at a time → staged release → observation/rollback gate
|
→ 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:
|
Preferred gates:
|
||||||
|
|
||||||
- Work Definition approval;
|
- Work Definition (no approval gate);
|
||||||
- Design Packet approval for standard/critical work;
|
- Design Packet (no approval gate);
|
||||||
- slice review when risk requires it;
|
- slice review when risk requires it;
|
||||||
- merge/release approval;
|
- merge/release approval;
|
||||||
- resolution of ambiguity or policy exceptions.
|
- resolution of ambiguity or policy exceptions.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ Dense context set for product development and coding agents.
|
|||||||
9. evaluation.md — quality measurement and improvement
|
9. evaluation.md — quality measurement and improvement
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
|
||||||
|
|
||||||
## Use by role
|
## Use by role
|
||||||
|
|
||||||
| Role | Minimum context |
|
| Role | Minimum context |
|
||||||
|
|||||||
12
docs/TECH.md
12
docs/TECH.md
@@ -198,9 +198,7 @@ All external/model payloads MUST be decoded with schemas at boundaries.
|
|||||||
```text
|
```text
|
||||||
Proposed
|
Proposed
|
||||||
→ Defining
|
→ Defining
|
||||||
→ AwaitingDefinitionApproval
|
|
||||||
→ Designing
|
→ Designing
|
||||||
→ AwaitingDesignApproval
|
|
||||||
→ Ready
|
→ Ready
|
||||||
→ ExecutingSlice
|
→ ExecutingSlice
|
||||||
→ VerifyingSlice
|
→ VerifyingSlice
|
||||||
@@ -242,7 +240,7 @@ Initial central aggregate:
|
|||||||
- Design Packet and versions;
|
- Design Packet and versions;
|
||||||
- slice/step graph;
|
- slice/step graph;
|
||||||
- Resolver state;
|
- Resolver state;
|
||||||
- approvals/questions;
|
- questions;
|
||||||
- artifact references;
|
- artifact references;
|
||||||
- result.
|
- 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.
|
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
|
## 10. Runtime strategy
|
||||||
|
|
||||||
@@ -580,7 +578,7 @@ Never create a “verified PR” from a different SHA than the verified candidat
|
|||||||
Review package:
|
Review package:
|
||||||
|
|
||||||
- original intent;
|
- original intent;
|
||||||
- approved definition/design;
|
- current definition/design;
|
||||||
- slice narrative;
|
- slice narrative;
|
||||||
- meaningful diffs;
|
- meaningful diffs;
|
||||||
- screenshots/video;
|
- 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;
|
- no long-lived model/Git secrets in workspace files;
|
||||||
- network egress policy;
|
- network egress policy;
|
||||||
- artifact access authorization;
|
- artifact access authorization;
|
||||||
- immutable audit trail for approvals;
|
- immutable audit trail for all Work events;
|
||||||
- tool allowlist per Kit;
|
- tool allowlist per Kit;
|
||||||
- destructive tools denied by default;
|
- destructive tools denied by default;
|
||||||
- merge/deploy human-gated initially;
|
- 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:
|
The architecture is proven when one real repository supports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
message → Signal → approved Work → approved Design Packet
|
message → Signal → proposed Work → ready Work
|
||||||
→ one slice → isolated harness run → independent verification
|
→ one slice → isolated harness run → independent verification
|
||||||
→ verified commit → real PR → human response/resume
|
→ verified commit → real PR → human response/resume
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ Prefer explicit state machines over booleans.
|
|||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
isRunning: boolean
|
isRunning: boolean;
|
||||||
isDone: boolean
|
isDone: boolean;
|
||||||
hasError: boolean
|
hasError: boolean;
|
||||||
```
|
```
|
||||||
|
|
||||||
Good:
|
Good:
|
||||||
@@ -213,7 +213,7 @@ type AttemptStatus =
|
|||||||
| "verification_failed"
|
| "verification_failed"
|
||||||
| "budget_exhausted"
|
| "budget_exhausted"
|
||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "permanent_failure"
|
| "permanent_failure";
|
||||||
```
|
```
|
||||||
|
|
||||||
Transitions must validate current version/state.
|
Transitions must validate current version/state.
|
||||||
@@ -281,30 +281,36 @@ Design replaceable boundaries, then ship one path.
|
|||||||
|
|
||||||
```md
|
```md
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
## Verification run
|
## Verification run
|
||||||
|
|
||||||
- `command` — pass/fail
|
- `command` — pass/fail
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
- candidate SHA/artifacts
|
- candidate SHA/artifacts
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
## Design deviations
|
## Design deviations
|
||||||
|
|
||||||
- none / ...
|
- none / ...
|
||||||
|
|
||||||
## Remaining risks or blockers
|
## Remaining risks or blockers
|
||||||
|
|
||||||
- none / ...
|
- none / ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 16. Required reading by task type
|
## 16. Required reading by task type
|
||||||
|
|
||||||
| Task | Read |
|
| Task | Read |
|
||||||
|---|---|
|
| ------------------ | ---------------------------------------- |
|
||||||
| product/domain | product.md, glossary.md |
|
| product/domain | product.md, glossary.md |
|
||||||
| UI | design.md, product.md |
|
| UI | design.md, product.md |
|
||||||
| actors/state | tech.md, glossary.md |
|
| actors/state | tech.md, glossary.md |
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
# Auth Proxy — Production Ingress Requirement
|
# Auth Proxy — Production Ingress Requirement
|
||||||
|
|
||||||
The application uses **same-origin authentication**: the browser and React Router SSR both hit
|
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.
|
||||||
`/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
|
## Required production route
|
||||||
|
|
||||||
At the public application domain, route:
|
At the public application domain, route:
|
||||||
|
|
||||||
| Prefix | Target |
|
| Prefix | Target |
|
||||||
|---|---|
|
| ------------- | --------------------- |
|
||||||
| `/api/auth/*` | Convex HTTP site |
|
| `/api/auth/*` | Convex HTTP site |
|
||||||
| `/*` | React Router frontend |
|
| `/*` | React Router frontend |
|
||||||
|
|
||||||
@@ -31,8 +29,7 @@ zopu.example.com {
|
|||||||
|
|
||||||
### Dokploy / Traefik
|
### Dokploy / Traefik
|
||||||
|
|
||||||
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
|
Create a higher-priority path router for `/api/auth` that forwards to the external Convex site URL. Ensure it:
|
||||||
site URL. Ensure it:
|
|
||||||
|
|
||||||
- Preserves the original browser `Cookie` header
|
- Preserves the original browser `Cookie` header
|
||||||
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
|
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
|
||||||
@@ -44,8 +41,7 @@ site URL. Ensure it:
|
|||||||
|
|
||||||
## Convex environment
|
## Convex environment
|
||||||
|
|
||||||
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
|
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex site URL:
|
||||||
site URL:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Production
|
# 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'
|
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
|
||||||
```
|
```
|
||||||
|
|
||||||
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
|
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.
|
||||||
`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`).
|
## 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
|
## Why same-origin
|
||||||
|
|
||||||
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
|
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
|
2. **SSR consistency.** The React Router server uses the same auth surface the browser sees; no cross-host credential translation.
|
||||||
sees; no cross-host credential translation.
|
|
||||||
3. **No CORS complexity.** The browser talks only to its own origin.
|
3. **No CORS complexity.** The browser talks only to its own origin.
|
||||||
4. **The reverse proxy handles the cross-host hop server-side.**
|
4. **The reverse proxy handles the cross-host hop server-side.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Deployment Notes
|
# Deployment Notes
|
||||||
|
|
||||||
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`).
|
Two active environments share one Convex deployment (`dev:befitting-dalmatian-161`). Each runs its own web server, Flue agents process, and `.env` file.
|
||||||
Each runs its own web server, Flue agents process, and `.env` file.
|
|
||||||
|
|
||||||
## Environments
|
## Environments
|
||||||
|
|
||||||
@@ -19,12 +18,9 @@ Each runs its own web server, Flue agents process, and `.env` file.
|
|||||||
|
|
||||||
## Shared Convex deployment
|
## Shared Convex deployment
|
||||||
|
|
||||||
Deployment name: `dev:befitting-dalmatian-161`
|
Deployment name: `dev:befitting-dalmatian-161` Convex URL: `https://befitting-dalmatian-161.convex.cloud` Convex Site URL: `https://befitting-dalmatian-161.convex.site`
|
||||||
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
|
Convex env vars are deployment-scoped, not per-machine. Authenticate from any machine with `npx convex dev` inside `packages/backend`.
|
||||||
machine with `npx convex dev` inside `packages/backend`.
|
|
||||||
|
|
||||||
Key Convex env var:
|
Key Convex env var:
|
||||||
|
|
||||||
@@ -32,9 +28,7 @@ Key Convex env var:
|
|||||||
SITE_URL = <the origin Better Auth should trust>
|
SITE_URL = <the origin Better Auth should trust>
|
||||||
```
|
```
|
||||||
|
|
||||||
This controls `trustedOrigins` in the Better Auth config
|
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.
|
||||||
(`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:
|
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'
|
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`
|
## Local Mac Dev `.env`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -143,16 +141,24 @@ zopu.cheaptricks.puter.wtf {
|
|||||||
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||||
encode zstd gzip
|
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/* {
|
handle_path /api/* {
|
||||||
reverse_proxy 127.0.0.1:3585
|
reverse_proxy 127.0.0.1:3585
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
reverse_proxy 127.0.0.1:5173
|
reverse_proxy 127.0.0.1:5173
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`/api/*` routes to the Flue agents process (port 3585).
|
`/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.
|
||||||
Everything else routes to the web dev server (port 5173).
|
|
||||||
|
|
||||||
Reload after changes:
|
Reload after changes:
|
||||||
|
|
||||||
@@ -162,8 +168,7 @@ sudo systemctl reload caddy
|
|||||||
|
|
||||||
### Vite allowedHosts
|
### Vite allowedHosts
|
||||||
|
|
||||||
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or
|
`apps/web/vite.config.ts` must include `server: { allowedHosts: true }` or Vite rejects requests arriving through the Caddy domain.
|
||||||
Vite rejects requests arriving through the Caddy domain.
|
|
||||||
|
|
||||||
### Start staging dev
|
### Start staging dev
|
||||||
|
|
||||||
@@ -211,23 +216,18 @@ Both environments use the same model via the Cheaptricks AI gateway:
|
|||||||
- Multimodal: text + image input
|
- Multimodal: text + image input
|
||||||
|
|
||||||
The `AGENT_MODEL_BASE_URL` differs:
|
The `AGENT_MODEL_BASE_URL` differs:
|
||||||
|
|
||||||
- Local Mac: uses the external gateway URL
|
- Local Mac: uses the external gateway URL
|
||||||
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
|
- Cheaptricks: uses `https://ai.cheaptricks.puter.wtf/v1` (local to the box)
|
||||||
|
|
||||||
## Known gotchas
|
## Known gotchas
|
||||||
|
|
||||||
1. **Convex SITE_URL is single-valued.** Only one origin can be trusted at a
|
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.
|
||||||
time. Switch it when moving between local and staging, or sign-in breaks
|
|
||||||
silently with a CORS error in the browser console.
|
|
||||||
|
|
||||||
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed
|
2. **Vite blocks unknown hosts by default.** Caddy domain must be allowed via `server.allowedHosts` in `apps/web/vite.config.ts`.
|
||||||
via `server.allowedHosts` in `apps/web/vite.config.ts`.
|
|
||||||
|
|
||||||
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at
|
3. **Flue port changed from 3583 to 3585.** The old Caddy config pointed at 3583/13100. Current ports are 3585 (Flue) and 5173 (web).
|
||||||
3583/13100. Current ports are 3585 (Flue) and 5173 (web).
|
|
||||||
|
|
||||||
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo
|
4. **`.env` is gitignored.** Each machine maintains its own copy. The repo ships `.env.example` as the template.
|
||||||
ships `.env.example` as the template.
|
|
||||||
|
|
||||||
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside
|
5. **Convex CLI auth is per-machine.** Run `npx convex dev` once inside `packages/backend` on each new machine to authenticate the CLI.
|
||||||
`packages/backend` on each new machine to authenticate the CLI.
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ The final integration branch should be:
|
|||||||
Your job is to manage the entire implementation through parallel isolated worktrees.
|
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:
|
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:
|
The completed proof should be something like:
|
||||||
|
|
||||||
User:
|
User: “Add a health endpoint that exposes the current build commit.”
|
||||||
“Add a health endpoint that exposes the current build commit.”
|
|
||||||
|
|
||||||
Result:
|
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
|
||||||
✓ 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.
|
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 events may represent execution steps and activity.
|
||||||
|
|
||||||
Project artifacts may represent:
|
Project artifacts may represent:
|
||||||
|
|
||||||
- work.md;
|
- work.md;
|
||||||
- steps.md;
|
- steps.md;
|
||||||
- agent reports;
|
- agent reports;
|
||||||
@@ -136,8 +123,7 @@ Project artifacts may represent:
|
|||||||
- commits;
|
- commits;
|
||||||
- pull requests.
|
- pull requests.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 3. OPERATING RULES
|
||||||
3. OPERATING RULES
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
Before changing anything:
|
Before changing anything:
|
||||||
@@ -197,22 +183,18 @@ When an agent is blocked:
|
|||||||
|
|
||||||
Do not stop after spawning agents. You must monitor and integrate them.
|
Do not stop after spawning agents. You must monitor and integrate them.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 4. INITIAL BRANCH PREPARATION
|
||||||
4. INITIAL BRANCH PREPARATION
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
Inspect the current branch.
|
Inspect the current branch.
|
||||||
|
|
||||||
If currently on feat/projects-backend:
|
If currently on feat/projects-backend:
|
||||||
|
|
||||||
git pull --ff-only
|
git pull --ff-only git switch -c dogfood/v0 git push -u origin dogfood/v0
|
||||||
git switch -c dogfood/v0
|
|
||||||
git push -u origin dogfood/v0
|
|
||||||
|
|
||||||
If dogfood/v0 already exists:
|
If dogfood/v0 already exists:
|
||||||
|
|
||||||
git switch dogfood/v0
|
git switch dogfood/v0 git pull --ff-only
|
||||||
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.
|
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,19 +202,16 @@ Run the baseline checks before spawning agents. Record failures but do not spend
|
|||||||
|
|
||||||
Suggested baseline:
|
Suggested baseline:
|
||||||
|
|
||||||
bun install
|
bun install bun run check
|
||||||
bun run check
|
|
||||||
|
|
||||||
Also inspect package-specific scripts and use the actual commands defined by the repository.
|
Also inspect package-specific scripts and use the actual commands defined by the repository.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 5. PASEO ORCHESTRATION
|
||||||
5. PASEO ORCHESTRATION
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
Verify Paseo first:
|
Verify Paseo first:
|
||||||
|
|
||||||
paseo status
|
paseo status paseo ls
|
||||||
paseo ls
|
|
||||||
|
|
||||||
If the daemon is unavailable, inspect:
|
If the daemon is unavailable, inspect:
|
||||||
|
|
||||||
@@ -278,28 +257,22 @@ Maintain an orchestration note in a temporary file outside committed product cod
|
|||||||
Track:
|
Track:
|
||||||
|
|
||||||
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
|
| Lane | Branch | Workspace ID | Agent ID | Status | PR |
|
||||||
|------|--------|--------------|----------|--------|----|
|
| ---- | ------ | ------------ | -------- | ------ | --- |
|
||||||
|
|
||||||
Use:
|
Use:
|
||||||
|
|
||||||
paseo ls
|
paseo ls paseo inspect <agentId> paseo logs <agentId> paseo send <agentId> "<focused instruction>" paseo wait <agentId>
|
||||||
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.
|
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:
|
Prepend the following instructions to every child prompt:
|
||||||
|
|
||||||
You are implementing one isolated lane of the Zopu dogfood v0 loop.
|
You are implementing one isolated lane of the Zopu dogfood v0 loop.
|
||||||
|
|
||||||
Repository base branch:
|
Repository base branch: dogfood/v0
|
||||||
dogfood/v0
|
|
||||||
|
|
||||||
Your branch is already isolated in a Paseo Git worktree.
|
Your branch is already isolated in a Paseo Git worktree.
|
||||||
|
|
||||||
@@ -347,13 +320,13 @@ Before finishing:
|
|||||||
- PR URL or exact PR creation instructions;
|
- PR URL or exact PR creation instructions;
|
||||||
- remaining limitations.
|
- remaining limitations.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 7. FIRST-WAVE PARALLEL LANES
|
||||||
7. FIRST-WAVE PARALLEL LANES
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
Spawn all four first-wave agents immediately.
|
Spawn all four first-wave agents immediately.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
---
|
||||||
|
|
||||||
LANE A — PROJECT CHAT, SIGNALS AND WORK ROUTING
|
LANE A — PROJECT CHAT, SIGNALS AND WORK ROUTING
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -370,6 +343,7 @@ Prompt:
|
|||||||
Implement the project-scoped Zopu chat, Signal extraction and work-routing loop.
|
Implement the project-scoped Zopu chat, Signal extraction and work-routing loop.
|
||||||
|
|
||||||
Inspect the existing:
|
Inspect the existing:
|
||||||
|
|
||||||
- Zopu agent;
|
- Zopu agent;
|
||||||
- Signals primitive;
|
- Signals primitive;
|
||||||
- ProjectIssue primitive;
|
- ProjectIssue primitive;
|
||||||
@@ -421,10 +395,10 @@ Rules:
|
|||||||
|
|
||||||
Acceptance examples:
|
Acceptance examples:
|
||||||
|
|
||||||
Message:
|
Message: “The work-unit card should expose the generated PR.”
|
||||||
“The work-unit card should expose the generated PR.”
|
|
||||||
|
|
||||||
Expected:
|
Expected:
|
||||||
|
|
||||||
- exact message persisted;
|
- exact message persisted;
|
||||||
- Signal created;
|
- Signal created;
|
||||||
- attached to an existing UI/workspace issue if relevant;
|
- attached to an existing UI/workspace issue if relevant;
|
||||||
@@ -449,7 +423,8 @@ Likely ownership:
|
|||||||
- packages/backend/convex/schema*
|
- packages/backend/convex/schema*
|
||||||
- Signal/project-issue primitives only as required.
|
- Signal/project-issue primitives only as required.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
---
|
||||||
|
|
||||||
LANE B — ORB RUNTIME FOUNDATION
|
LANE B — ORB RUNTIME FOUNDATION
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -525,6 +500,7 @@ Keep product-domain concepts separate from infrastructure leases.
|
|||||||
Do not make AgentOS VM state equal the work-unit state.
|
Do not make AgentOS VM state equal the work-unit state.
|
||||||
|
|
||||||
Actor identity must include:
|
Actor identity must include:
|
||||||
|
|
||||||
- project;
|
- project;
|
||||||
- issue/work unit;
|
- issue/work unit;
|
||||||
- run.
|
- run.
|
||||||
@@ -577,11 +553,13 @@ Document:
|
|||||||
- current limitations.
|
- current limitations.
|
||||||
|
|
||||||
Do not modify:
|
Do not modify:
|
||||||
|
|
||||||
- Zopu chat behavior;
|
- Zopu chat behavior;
|
||||||
- project-manager delegation;
|
- project-manager delegation;
|
||||||
- web UI.
|
- web UI.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
---
|
||||||
|
|
||||||
LANE C — WEB WORKSPACE AND WORK CARDS
|
LANE C — WEB WORKSPACE AND WORK CARDS
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -613,18 +591,21 @@ Product projection:
|
|||||||
Required interface:
|
Required interface:
|
||||||
|
|
||||||
Header:
|
Header:
|
||||||
|
|
||||||
- Zopu;
|
- Zopu;
|
||||||
- current project;
|
- current project;
|
||||||
- project/runtime connection status;
|
- project/runtime connection status;
|
||||||
- basic project switcher if already supported.
|
- basic project switcher if already supported.
|
||||||
|
|
||||||
Main surface:
|
Main surface:
|
||||||
|
|
||||||
- global project conversation;
|
- global project conversation;
|
||||||
- active Work Unit cards;
|
- active Work Unit cards;
|
||||||
- lightweight Signals display;
|
- lightweight Signals display;
|
||||||
- persistent composer.
|
- persistent composer.
|
||||||
|
|
||||||
Collapsed Work Unit card:
|
Collapsed Work Unit card:
|
||||||
|
|
||||||
- title;
|
- title;
|
||||||
- latest summary;
|
- latest summary;
|
||||||
- linked Signal count;
|
- linked Signal count;
|
||||||
@@ -635,6 +616,7 @@ Collapsed Work Unit card:
|
|||||||
- needs-input indicator when available.
|
- needs-input indicator when available.
|
||||||
|
|
||||||
Expanded Work Unit:
|
Expanded Work Unit:
|
||||||
|
|
||||||
- objective;
|
- objective;
|
||||||
- linked Signals and provenance;
|
- linked Signals and provenance;
|
||||||
- current plan or steps;
|
- current plan or steps;
|
||||||
@@ -705,7 +687,8 @@ Tests:
|
|||||||
- empty state;
|
- empty state;
|
||||||
- needs-input display.
|
- needs-input display.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
---
|
||||||
|
|
||||||
LANE D — SINGLE-NODE EXECUTION DEPLOYMENT
|
LANE D — SINGLE-NODE EXECUTION DEPLOYMENT
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -794,8 +777,7 @@ The deployment must be reproducible on a clean Debian host.
|
|||||||
|
|
||||||
Do not require the developer’s MacBook to remain online after deployment.
|
Do not require the developer’s MacBook to remain online after deployment.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 8. FIRST-WAVE MONITORING
|
||||||
8. FIRST-WAVE MONITORING
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
After spawning all four first-wave agents:
|
After spawning all four first-wave agents:
|
||||||
@@ -823,8 +805,7 @@ Examples:
|
|||||||
|
|
||||||
Wait until each first-wave agent is idle, then inspect:
|
Wait until each first-wave agent is idle, then inspect:
|
||||||
|
|
||||||
paseo inspect <agentId>
|
paseo inspect <agentId> paseo logs <agentId>
|
||||||
paseo logs <agentId>
|
|
||||||
|
|
||||||
Review each branch yourself.
|
Review each branch yourself.
|
||||||
|
|
||||||
@@ -842,8 +823,7 @@ For every lane:
|
|||||||
|
|
||||||
Do not merge a failing lane merely because the child says it works.
|
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:
|
Recommended merge order:
|
||||||
@@ -855,11 +835,7 @@ Recommended merge order:
|
|||||||
|
|
||||||
Before every merge:
|
Before every merge:
|
||||||
|
|
||||||
git switch dogfood/v0
|
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 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.
|
Run lane-specific checks.
|
||||||
|
|
||||||
@@ -873,14 +849,11 @@ Resolve conflicts conservatively.
|
|||||||
|
|
||||||
After each merge:
|
After each merge:
|
||||||
|
|
||||||
bun install
|
bun install run targeted checks git push origin dogfood/v0
|
||||||
run targeted checks
|
|
||||||
git push origin dogfood/v0
|
|
||||||
|
|
||||||
Do not squash away useful commits unless repository policy requires squashing.
|
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.
|
Only spawn this lane after the Orb runtime branch is integrated into dogfood/v0.
|
||||||
@@ -944,20 +917,13 @@ Refactor the current Git lifecycle so it can be invoked from the Orb/application
|
|||||||
|
|
||||||
Failure mapping:
|
Failure mapping:
|
||||||
|
|
||||||
- runtime unavailable:
|
- runtime unavailable: infrastructure failure with clear reason;
|
||||||
infrastructure failure with clear reason;
|
- missing user context: needs input;
|
||||||
- missing user context:
|
- failed tests that can be repaired: agent continues;
|
||||||
needs input;
|
- unrecoverable repeated failure: failed with logs and evidence;
|
||||||
- failed tests that can be repaired:
|
- cancellation: terminate OpenCode and sandbox processes;
|
||||||
agent continues;
|
- Git rejection: preserve commit and expose exact failure;
|
||||||
- unrecoverable repeated failure:
|
- PR creation failure: retain pushed branch and retry safely.
|
||||||
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:
|
Idempotency:
|
||||||
|
|
||||||
@@ -981,8 +947,7 @@ Tests:
|
|||||||
|
|
||||||
Do not add multi-agent parallel execution.
|
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.
|
Monitor the orb-wiring agent closely.
|
||||||
@@ -1007,8 +972,7 @@ Review and merge it into dogfood/v0 only after:
|
|||||||
- idempotency is demonstrated;
|
- idempotency is demonstrated;
|
||||||
- no secrets appear in logs or artifacts.
|
- 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.
|
After all implementation lanes are merged, spawn the final integration agent.
|
||||||
@@ -1119,8 +1083,7 @@ It must contain:
|
|||||||
|
|
||||||
Commit, push and open a PR targeting dogfood/v0.
|
Commit, push and open a PR targeting dogfood/v0.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 13. FINAL INTEGRATION RESPONSIBILITIES
|
||||||
13. FINAL INTEGRATION RESPONSIBILITIES
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
After the final agent completes:
|
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.
|
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.
|
A lane is not done merely because code was generated.
|
||||||
@@ -1184,8 +1146,7 @@ The complete v0 must satisfy:
|
|||||||
- PR is created;
|
- PR is created;
|
||||||
- PR is visible in the UI.
|
- PR is visible in the UI.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 15. RESOURCE AND CONCURRENCY LIMITS
|
||||||
15. RESOURCE AND CONCURRENCY LIMITS
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
For this v0:
|
For this v0:
|
||||||
@@ -1205,8 +1166,7 @@ When agents are no longer needed:
|
|||||||
|
|
||||||
Do not archive them until their logs and work have been reviewed.
|
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.
|
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.
|
Continue supervising all unblocked lanes while waiting.
|
||||||
|
|
||||||
======================================================================
|
====================================================================== 17. FINAL REPORT
|
||||||
17. FINAL REPORT
|
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
When finished, provide a concise but complete report:
|
When finished, provide a concise but complete report:
|
||||||
@@ -1254,6 +1213,7 @@ When finished, provide a concise but complete report:
|
|||||||
## Integrated branches
|
## Integrated branches
|
||||||
|
|
||||||
For each branch:
|
For each branch:
|
||||||
|
|
||||||
- purpose;
|
- purpose;
|
||||||
- final commit;
|
- final commit;
|
||||||
- PR;
|
- PR;
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ Observe real Work with human decisions, merge outcomes, incidents, and post-rele
|
|||||||
Score each candidate run 0–3 per dimension.
|
Score each candidate run 0–3 per dimension.
|
||||||
|
|
||||||
| Score | Meaning |
|
| Score | Meaning |
|
||||||
|---|---|
|
| ----- | --------------------------------- |
|
||||||
| 0 | unsafe/incorrect/missing |
|
| 0 | unsafe/incorrect/missing |
|
||||||
| 1 | substantial human repair required |
|
| 1 | substantial human repair required |
|
||||||
| 2 | acceptable with minor correction |
|
| 2 | acceptable with minor correction |
|
||||||
|
|||||||
147
docs/git-provider-setup.md
Normal file
147
docs/git-provider-setup.md
Normal 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 |
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# Slice 1 Handoff Notes
|
# Slice 1 Handoff Notes
|
||||||
|
|
||||||
Date: 2026-07-27
|
Date: 2026-07-27 Branch: `master` at `cfdb2efc7` PR: #19 (merged)
|
||||||
Branch: `master` at `cfdb2efc7`
|
|
||||||
PR: #19 (merged)
|
|
||||||
|
|
||||||
## What was built
|
## What was built
|
||||||
|
|
||||||
### Core product loop
|
### Core product loop
|
||||||
|
|
||||||
- Conversation to Signal to proposed Work with exact source provenance.
|
- Conversation to Signal to proposed Work with exact source provenance.
|
||||||
- Convex owns durable data: `conversationMessages`, `signals`, `works`, `workEvents`.
|
- Convex owns durable data: `conversationMessages`, `signals`, `works`, `workEvents`.
|
||||||
- Effect validation in `@code/work-os` package.
|
- 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`.
|
- Mobile-first UI at `apps/web/src/components/slice-one/slice-one-page.tsx`.
|
||||||
|
|
||||||
### Model
|
### Model
|
||||||
|
|
||||||
- MiMo V2.5 (`xiaomi/mimo-v2.5`) via Cheaptricks AI gateway.
|
- MiMo V2.5 (`xiaomi/mimo-v2.5`) via Cheaptricks AI gateway.
|
||||||
- Provider identity `xiaomi` gives Flue correct multimodal metadata (text + image).
|
- Provider identity `xiaomi` gives Flue correct multimodal metadata (text + image).
|
||||||
- `AGENT_MODEL_BASE_URL` points at Cheaptricks; API key in `.env`.
|
- `AGENT_MODEL_BASE_URL` points at Cheaptricks; API key in `.env`.
|
||||||
@@ -22,28 +22,33 @@ PR: #19 (merged)
|
|||||||
- `thinkingLevel: "medium"`.
|
- `thinkingLevel: "medium"`.
|
||||||
|
|
||||||
### Reasoning traces
|
### Reasoning traces
|
||||||
|
|
||||||
- Native Flue `reasoning` parts render as live "Thinking trace" (open while streaming, collapsed after).
|
- 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.
|
- Inline `<think>...</think>` extraction for models that embed thinking in text.
|
||||||
- No fallback that hides model output.
|
- No fallback that hides model output.
|
||||||
|
|
||||||
### Image attachments
|
### Image attachments
|
||||||
|
|
||||||
- Picker button + hidden file input, up to 4 images at 10 MB each.
|
- Picker button + hidden file input, up to 4 images at 10 MB each.
|
||||||
- Base64 conversion into Flue `AgentPromptImage`.
|
- Base64 conversion into Flue `AgentPromptImage`.
|
||||||
- Authenticated blob-URL rendering for historical image replay.
|
- Authenticated blob-URL rendering for historical image replay.
|
||||||
- Verified live: MiMo read exact text and background color from generated test images.
|
- Verified live: MiMo read exact text and background color from generated test images.
|
||||||
|
|
||||||
### Mobile keyboard fix
|
### Mobile keyboard fix
|
||||||
|
|
||||||
- Visual viewport hook (`use-visual-viewport.ts`).
|
- Visual viewport hook (`use-visual-viewport.ts`).
|
||||||
- Fixed full-screen shell; `interactive-widget=resizes-content` in viewport meta.
|
- Fixed full-screen shell; `interactive-widget=resizes-content` in viewport meta.
|
||||||
- Header stays pinned; chat shrinks; composer follows keyboard.
|
- Header stays pinned; chat shrinks; composer follows keyboard.
|
||||||
- Regression tests in `frontend-regressions.test.ts` and `use-visual-viewport.test.ts`.
|
- Regression tests in `frontend-regressions.test.ts` and `use-visual-viewport.test.ts`.
|
||||||
|
|
||||||
### Rendering
|
### Rendering
|
||||||
|
|
||||||
- Streamdown markdown streaming with Mermaid chart support.
|
- Streamdown markdown streaming with Mermaid chart support.
|
||||||
- Tool turns hidden; reasoning-containing tool turns remain visible.
|
- Tool turns hidden; reasoning-containing tool turns remain visible.
|
||||||
- Work cards render inline in the conversation timeline with source navigation.
|
- Work cards render inline in the conversation timeline with source navigation.
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
- Cheaptricks staging live at `https://zopu.cheaptricks.puter.wtf`.
|
- Cheaptricks staging live at `https://zopu.cheaptricks.puter.wtf`.
|
||||||
- Caddy reverse proxy: `/api/*` to Flue (port 3585), root to web (port 5173).
|
- 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.
|
- `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`.
|
- Deployment notes at `docs/deployment.md`.
|
||||||
|
|
||||||
### Repository cleanup
|
### Repository cleanup
|
||||||
|
|
||||||
- 17 stale worktrees removed (Codex, Paseo, T3).
|
- 17 stale worktrees removed (Codex, Paseo, T3).
|
||||||
- 23 stale local branches deleted.
|
- 23 stale local branches deleted.
|
||||||
- 18 stale remote branches deleted.
|
- 18 stale remote branches deleted.
|
||||||
@@ -62,16 +68,19 @@ PR: #19 (merged)
|
|||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
- Cheaptricks: web on `:5173`, Flue on `:3585`, both persistent via `nohup`.
|
- 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.
|
- Local Mac: servers stopped. Run `bun run dev:tailscale:agents -- --port 3585` and `bun run dev:tailscale:web` to start.
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
- `master` at `cfdb2efc7` on local, Cheaptricks, and remote.
|
- `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).
|
- 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
|
## Known issues and next steps
|
||||||
|
|
||||||
1. **Convex SITE_URL is single-valued.** Switch it when moving between local and staging:
|
1. **Convex SITE_URL is single-valued.** Switch it when moving between local and staging:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/backend
|
cd packages/backend
|
||||||
npx convex env set SITE_URL 'http://100.101.157.28:5173' # local
|
npx convex env set SITE_URL 'http://100.101.157.28:5173' # local
|
||||||
|
|||||||
@@ -3,4 +3,10 @@ import ultracite from "ultracite/oxfmt";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
...ultracite,
|
...ultracite,
|
||||||
|
ignorePatterns: [
|
||||||
|
...ultracite.ignorePatterns,
|
||||||
|
"**/.agents/skills/**",
|
||||||
|
"repos/**",
|
||||||
|
"scripts/**",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,27 +5,43 @@ import remix from "ultracite/oxlint/remix";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
extends: [core, react, remix],
|
extends: [core, react, remix],
|
||||||
ignorePatterns: [...core.ignorePatterns, "repos/**", "scripts/**"],
|
ignorePatterns: [
|
||||||
|
...core.ignorePatterns,
|
||||||
|
"**/.agents/skills/**",
|
||||||
|
"repos/**",
|
||||||
|
"scripts/**",
|
||||||
|
],
|
||||||
overrides: [
|
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"],
|
files: ["**/convex/**/*.ts", "**/convex/**/*.test.ts"],
|
||||||
rules: {
|
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
|
// Convex query builders use `any` in index callbacks
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-non-null-assertion": "off",
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
"eslint/complexity": "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"],
|
files: ["**/convex/**/*.test.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
"unicorn/no-await-expression-member": "off",
|
|
||||||
"eslint/require-await": "off",
|
"eslint/require-await": "off",
|
||||||
"sort-keys": "off",
|
"sort-keys": "off",
|
||||||
|
"unicorn/no-await-expression-member": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
81
package.json
81
package.json
@@ -1,45 +1,106 @@
|
|||||||
{
|
{
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": {
|
||||||
|
"packages": [
|
||||||
|
"apps/web",
|
||||||
|
"packages/agents",
|
||||||
|
"packages/auth",
|
||||||
|
"packages/backend",
|
||||||
|
"packages/config",
|
||||||
|
"packages/env",
|
||||||
|
"packages/primitives",
|
||||||
|
"packages/ui"
|
||||||
|
],
|
||||||
|
"catalog": {
|
||||||
|
"@rivet-dev/agentos": "0.2.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",
|
||||||
|
"lucide-react": "1.27.0",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"sonner": "2.0.7",
|
||||||
|
"convex": "1.42.3",
|
||||||
|
"better-auth": "1.6.15",
|
||||||
|
"@convex-dev/better-auth": "0.12.5",
|
||||||
|
"@tanstack/react-form": "1.33.2",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"tailwind-merge": "3.6.0",
|
||||||
|
"@better-auth/expo": "1.6.15",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"@types/bun": "1.3.14",
|
||||||
|
"heroui-native": "1.0.6",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10",
|
||||||
|
"convex-test": "0.0.54",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/node": "22.20.1",
|
||||||
|
"hono": "4.12.32",
|
||||||
|
"valibot": "1.4.2",
|
||||||
|
"streamdown": "2.5.0",
|
||||||
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
"@tailwindcss/vite": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vp run -r dev",
|
"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",
|
"build": "vp run -r build",
|
||||||
"check-types": "vp run -r check-types",
|
"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",
|
"lint": "oxlint --disable-nested-config",
|
||||||
"format": "vp fmt",
|
"format": "vp fmt",
|
||||||
"staged": "vp staged",
|
"staged": "vp staged",
|
||||||
"hooks:setup": "vp config",
|
"hooks:setup": "vp config",
|
||||||
"dev:web": "vp run --filter web dev",
|
"dev:web": "vp --filter web run dev",
|
||||||
"dev:agents": "vp run --filter @code/agents dev",
|
"dev:agents": "vp --filter @code/agents run dev",
|
||||||
"dev:tailscale:web": "vp run --filter web dev:tailscale",
|
"dev:tailscale:web": "vp --filter web run dev:tailscale",
|
||||||
"dev:tailscale:agents": "vp run --filter @code/agents dev:tailscale",
|
"dev:tailscale:agents": "vp --filter @code/agents run dev:tailscale",
|
||||||
"dev:server": "vp run --filter @code/backend dev",
|
"dev:server": "vp --filter @code/backend run dev",
|
||||||
"dev:setup": "vp run --filter @code/backend dev:setup",
|
"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",
|
"build:agents": "vp run --filter @code/agents build",
|
||||||
"docs:update": "node scripts/update-docs.ts",
|
"docs:update": "node scripts/update-docs.ts",
|
||||||
"subtree": "node scripts/subtree.ts",
|
"subtree": "node scripts/subtree.ts",
|
||||||
"fix": "ultracite fix"
|
"vercel-build": "node scripts/vercel-build.ts",
|
||||||
|
"check": "ultracite check --disable-nested-config",
|
||||||
|
"fix": "ultracite fix --disable-nested-config"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/sdk": "1.0.0-beta.9",
|
"@flue/sdk": "npm:@rivet-dev/labs-flue-sdk@1.0.0-beta.9-rivet.2",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@vercel/config": "^0.5.5",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"convex-test": "catalog:",
|
"convex-test": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"oxfmt": "0.61.0",
|
"oxfmt": "0.61.0",
|
||||||
"oxlint": "1.76.0",
|
"oxlint": "1.76.0",
|
||||||
|
"react-router": "^8.1.0",
|
||||||
"rolldown": "1.1.4",
|
"rolldown": "1.1.4",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"ultracite": "7.9.3",
|
"ultracite": "7.9.3",
|
||||||
"vite-plus": "0.2.2",
|
"vite-plus": "0.2.2",
|
||||||
"vitest": "catalog:"
|
"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"
|
"packageManager": "pnpm@11.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,36 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 . .
|
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
|
RUN node packages/agents/node_modules/@flue/cli/bin/flue.mjs build --target node --root packages/agents
|
||||||
|
|
||||||
FROM node:24-bookworm-slim
|
FROM node:24-bookworm-slim
|
||||||
|
|
||||||
ENV NODE_OPTIONS=--experimental-specifier-resolution=node
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /app /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
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "packages/agents/dist/server.mjs"]
|
CMD ["node", "packages/agents/dist/server.mjs"]
|
||||||
|
|||||||
84
packages/agents/Dockerfile.runner
Normal file
84
packages/agents/Dockerfile.runner
Normal 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"]
|
||||||
@@ -3,3 +3,11 @@ import { defineConfig } from "@flue/cli/config";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
target: "node",
|
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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,29 +7,27 @@
|
|||||||
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
|
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
|
||||||
"start": "node --env-file=../../.env dist/server.mjs",
|
"start": "node --env-file=../../.env dist/server.mjs",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"dev": "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:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
"dev:engine": "node --env-file=../../.env scripts/start-engine.mjs",
|
||||||
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
|
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
|
||||||
"runner": "node --env-file=../../.env src/runner.ts",
|
"runner": "bun --env-file=../../.env src/runner.ts",
|
||||||
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
|
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu"
|
||||||
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentos-software/git": "0.3.3",
|
"@agentos-software/git": "0.3.3",
|
||||||
"@code/backend": "workspace:*",
|
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "npm:@rivet-dev/labs-flue-runtime@1.0.0-beta.9-rivet.2",
|
||||||
"@rivet-dev/agentos": "0.2.14",
|
"@rivet-dev/agentos": "catalog:",
|
||||||
"@rivet-dev/agentos-core": "0.2.14",
|
"@rivet-dev/agentos-core": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"rivetkit": "2.3.9",
|
|
||||||
"valibot": "catalog:"
|
"valibot": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@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/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
13
packages/agents/scripts/start-engine.mjs
Normal file
13
packages/agents/scripts/start-engine.mjs
Normal 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);
|
||||||
|
});
|
||||||
18
packages/agents/src/adapters/convex-client.ts
Normal file
18
packages/agents/src/adapters/convex-client.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
1431
packages/agents/src/adapters/flue-persistence.ts
Normal file
1431
packages/agents/src/adapters/flue-persistence.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
import { parseAgentEnv } from "@code/env/agent";
|
import { parseAgentEnv } from "@code/env/agent";
|
||||||
import { defineAgent } from "@flue/runtime";
|
import { defineAgent } from "@flue/runtime";
|
||||||
import { local } from "@flue/runtime/node";
|
|
||||||
|
|
||||||
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
|
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 {
|
export {
|
||||||
convexAgentRoute as attachments,
|
conversationRoute as attachments,
|
||||||
convexAgentRoute as route,
|
conversationRoute as route,
|
||||||
} from "../auth";
|
} from "../auth/conversation-route";
|
||||||
|
|
||||||
// The host checkout the chat agent explores. The `local()` sandbox reuses the
|
|
||||||
// machine's existing shell, Git, and Tea install unchanged; its cwd becomes the
|
|
||||||
// default working directory for every command the agent runs.
|
|
||||||
const ZOPU_CODE_PATH = "/Users/puter/Workspace/zopu/code";
|
|
||||||
|
|
||||||
export default defineAgent(({ env, id }) => {
|
export default defineAgent(({ env, id }) => {
|
||||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description:
|
description:
|
||||||
"Turns actionable conversation into provenanced Signals and proposed Work.",
|
"Turns actionable conversation into provenanced Signals, proposed Work, and execution requests.",
|
||||||
instructions: INSTRUCTIONS,
|
instructions: INSTRUCTIONS,
|
||||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "high",
|
||||||
sandbox: local({ cwd: ZOPU_CODE_PATH }),
|
tools: [
|
||||||
tools: createSliceOneTools(id, env),
|
...createSignalRoutingTools(id, env),
|
||||||
|
...createWorkCommandTools(id, env),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { parseAgentEnv } from "@code/env/agent";
|
|
||||||
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
|
|
||||||
import { registerProvider } from "@flue/runtime";
|
import { registerProvider } from "@flue/runtime";
|
||||||
import type { Fetchable } from "@flue/runtime/routing";
|
import type { Fetchable } from "@flue/runtime/routing";
|
||||||
import { flue } from "@flue/runtime/routing";
|
import { flue } from "@flue/runtime/routing";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { internalRoute } from "./auth/internal-route";
|
||||||
|
import { parseAgentEnv } from "./runtime/agent-env";
|
||||||
import {
|
import {
|
||||||
cancelAgentOsAttempt,
|
cancelAgentOsAttempt,
|
||||||
executeAgentOsAttempt,
|
executeAgentOsAttempt,
|
||||||
runtimeRegistry,
|
} from "./runtime/attempt-runner";
|
||||||
} from "./runtime/agent-os";
|
import { WorkAttemptExecutionError } from "./runtime/zopu-primitives";
|
||||||
|
|
||||||
const agentEnv = parseAgentEnv(process.env);
|
const agentEnv = parseAgentEnv(process.env);
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.post("/internal/work-attempts/execute", async (context) => {
|
// Unauthenticated liveness probe used by the VDS Compose healthcheck and CI
|
||||||
if (
|
// staging verification. It must not depend on Convex, Rivet, or the model
|
||||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
// provider; it only confirms the Flue Node process is accepting requests.
|
||||||
) {
|
app.get("/health", (context) => context.json({ status: "ok" }));
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
app.post("/internal/work-attempts/execute", internalRoute, async (context) => {
|
||||||
try {
|
try {
|
||||||
return context.json(await executeAgentOsAttempt(await context.req.json()));
|
return context.json(await executeAgentOsAttempt(await context.req.json()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,21 +65,21 @@ app.post("/internal/work-attempts/execute", async (context) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
|
app.post(
|
||||||
if (
|
"/internal/work-attempts/:workspaceKey/cancel",
|
||||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
internalRoute,
|
||||||
) {
|
async (context) => {
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
const body = (await context.req.json()) as { attemptId?: unknown };
|
const body = (await context.req.json()) as { attemptId?: unknown };
|
||||||
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
|
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
|
||||||
return context.json({ error: "attemptId is required" }, 400);
|
return context.json({ error: "attemptId is required" }, 400);
|
||||||
}
|
}
|
||||||
await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
|
await cancelAgentOsAttempt(
|
||||||
|
context.req.param("workspaceKey"),
|
||||||
|
body.attemptId
|
||||||
|
);
|
||||||
return context.json({ cancelled: true });
|
return context.json({ cancelled: true });
|
||||||
});
|
}
|
||||||
|
);
|
||||||
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
|
|
||||||
|
|
||||||
app.route("/", flue());
|
app.route("/", flue());
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
|
||||||
};
|
|
||||||
73
packages/agents/src/auth/conversation-route.ts
Normal file
73
packages/agents/src/auth/conversation-route.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
12
packages/agents/src/auth/internal-route.ts
Normal file
12
packages/agents/src/auth/internal-route.ts
Normal 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();
|
||||||
|
};
|
||||||
39
packages/agents/src/auth/workflow-route.ts
Normal file
39
packages/agents/src/auth/workflow-route.ts
Normal 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
@@ -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
|
## 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.
|
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:
|
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.
|
||||||
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:
|
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.
|
||||||
a. Call list_proposed_work for the project.
|
|
||||||
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
|
|
||||||
c. Otherwise call create_work_from_signal.
|
|
||||||
e. If genuinely uncertain whether to attach or create, ask one focused question.
|
|
||||||
|
|
||||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
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 linked it to [Work title]."
|
||||||
- "Captured [Signal title] and proposed [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.
|
- Keep the response brief; the product renders the durable Work card separately.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
@@ -39,18 +34,7 @@ When a user sends a message, follow this decision flow:
|
|||||||
- Ask at most one focused clarification when genuinely ambiguous.
|
- Ask at most one focused clarification when genuinely ambiguous.
|
||||||
- Preserve project and organization scope at all times.
|
- 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.
|
- 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.
|
- Never claim you created a Signal, started Work, or produced a result until the tool call returns successfully.
|
||||||
- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
|
- Do not perform implementation, planning, design, verification, or delivery yourself. Those are handled by dedicated workflows and the execution runtime.
|
||||||
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user