Compare commits
55 Commits
t3code/rev
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f53f550d | ||
|
|
54a5993274 | ||
|
|
def3fc949a | ||
|
|
9f7dd3c1a6 | ||
|
|
a8b2ff5e2e | ||
|
|
40e0f7e1eb | ||
|
|
bf2300a6be | ||
|
|
0657037c84 | ||
|
|
64a783b445 | ||
|
|
ed28943e7a | ||
|
|
9e148489f0 | ||
|
|
25f86d94cc | ||
|
|
7668fa69cc | ||
|
|
3ffa1cfc7c | ||
|
|
18eb150d7d | ||
|
|
e1b0b731e0 | ||
|
|
d428a2492b | ||
|
|
fe0fd9b16c | ||
|
|
fc1fcf5d44 | ||
|
|
3ae72864bd | ||
|
|
0d5d54caa8 | ||
|
|
830bcc4756 | ||
|
|
0e56a462cd | ||
|
|
9fb293a539 | ||
|
|
062c00f53c | ||
|
|
a7e70c9b2a | ||
|
|
526ed59776 | ||
|
|
fd3980c6bf | ||
|
|
d8a4bbe804 | ||
|
|
9eb6bcd25f | ||
|
|
a907539810 | ||
|
|
601aca73c2 | ||
|
|
ffecff3857 | ||
|
|
0d7162544b | ||
|
|
092a9793ea | ||
|
|
5ee0a8d50e | ||
|
|
420676f2d7 | ||
|
|
24d82e2a06 | ||
|
|
d47fa0e96a | ||
|
|
1e7c893985 | ||
|
|
f9ebcb4a01 | ||
|
|
dceaa2b417 | ||
|
|
a4f121e190 | ||
|
|
4edd456b5b | ||
|
|
4d9f6da41b | ||
|
|
a53029cf7a | ||
|
|
eede4c10ba | ||
|
|
35169672e1 | ||
|
|
359d9e2285 | ||
|
|
05a3baaac3 | ||
|
|
4cc40cb2e4 | ||
|
|
814df02be9 | ||
|
|
d7f6cbdcdc | ||
|
|
a8d946b6a9 | ||
| 0e32c35515 |
@@ -17,7 +17,11 @@ DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
# RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVET_ENDPOINT=http://localhost:6420
|
||||
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
||||
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
||||
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
|
||||
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
|
||||
|
||||
# Flue persistence adapter
|
||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,3 +54,4 @@ coverage
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
.env.*
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"files.exclude": {
|
||||
"repos/**": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"repos/**": true
|
||||
},
|
||||
|
||||
36
apps/web/Dockerfile
Normal file
36
apps/web/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
ARG VITE_AUTH_URL
|
||||
ARG VITE_CONVEX_URL
|
||||
ENV VITE_AUTH_URL=$VITE_AUTH_URL
|
||||
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun run --filter web build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]
|
||||
@@ -26,14 +26,14 @@
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0"
|
||||
"streamdown": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@react-router/dev": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"react-router-devtools": "^6.2.1",
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
ChevronRight,
|
||||
Check,
|
||||
FolderGit2,
|
||||
Hammer,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
MessageSquareText,
|
||||
ImagePlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
|
||||
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
|
||||
import {
|
||||
buildSliceOneTimeline,
|
||||
findSourceMessageTarget,
|
||||
} from "@/lib/slice-one/presentation";
|
||||
|
||||
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
|
||||
const EMPTY_WORKS: readonly SliceWork[] = [];
|
||||
|
||||
interface WorkCardProps {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly work: SliceWork;
|
||||
readonly slice: SliceOneState;
|
||||
}
|
||||
|
||||
const starterDefinition = (work: SliceWork) => ({
|
||||
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
||||
affectedUsers: ["Project users"],
|
||||
assumptions: [],
|
||||
constraints: [],
|
||||
desiredOutcome: work.objective,
|
||||
inScope: [work.objective],
|
||||
outOfScope: ["Unrelated product changes"],
|
||||
problem: work.objective,
|
||||
questions: [],
|
||||
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
||||
risk: "medium",
|
||||
});
|
||||
|
||||
const starterDesign = (work: SliceWork) => ({
|
||||
architectureSummary:
|
||||
"Validate the approved Definition, then exercise one deterministic fake slice.",
|
||||
callFlowDelta: [
|
||||
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
|
||||
],
|
||||
concerns: [],
|
||||
evidenceRequirements: ["Terminal Run classification"],
|
||||
fileTreeDelta: [],
|
||||
impactMap: {
|
||||
files: [],
|
||||
modules: [],
|
||||
risks: [],
|
||||
summary: "Compact vertical-slice simulation",
|
||||
},
|
||||
invariants: [
|
||||
"Simulation never claims implementation",
|
||||
"Every Attempt reaches a terminal classification",
|
||||
],
|
||||
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
||||
slices: [
|
||||
{
|
||||
codeBoundaries: ["workExecution"],
|
||||
dependsOn: [],
|
||||
evidenceRequirements: ["Normalized activity events"],
|
||||
id: "slice-1",
|
||||
objective: work.objective,
|
||||
observableBehavior: "A terminal fake Run is visible",
|
||||
reviewRequired: false,
|
||||
title: "Deterministic simulation",
|
||||
verification: ["Run completes with a terminal classification"],
|
||||
},
|
||||
],
|
||||
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line complexity -- the expanded card intentionally keeps the three review sections together.
|
||||
const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||
const { definition } = work;
|
||||
const { design } = work;
|
||||
const [latestRun] = work.runs;
|
||||
return (
|
||||
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Proposed Work
|
||||
</p>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||
{work.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||
{work.objective}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||
onClick={() => setSourcesOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{sources.length} exact source{" "}
|
||||
{sources.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{sourcesOpen ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||
key={source.messageId}
|
||||
onClick={() => onSourceSelect(source.rawText)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
|
||||
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Outcome
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
||||
<p className="mt-2 text-[#747168]">
|
||||
Risk: {definition?.risk ?? "not defined"}
|
||||
</p>
|
||||
{definition?.questions?.length ? (
|
||||
<p className="mt-1 text-amber-800">
|
||||
{
|
||||
definition.questions.filter(
|
||||
(question) => question.status === "open"
|
||||
).length
|
||||
}{" "}
|
||||
open question(s)
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "proposed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void slice.requestDefinition(work._id)}
|
||||
>
|
||||
<Sparkles className="size-3.5" /> Define
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "defining" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void slice.saveDefinition(work._id, starterDefinition(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save definition
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-definition-approval" &&
|
||||
work.definitionVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.approveDefinition(
|
||||
work._id,
|
||||
work.definitionVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Design
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
{design?.architectureSummary ?? "No Design Packet yet."}
|
||||
</p>
|
||||
{design?.slices?.map((item) => (
|
||||
<p className="mt-1 text-[#747168]" key={item.id}>
|
||||
{item.title}: {item.observableBehavior}
|
||||
</p>
|
||||
))}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "designing" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void slice.saveDesign(work._id, starterDesign(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save design
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-design-approval" &&
|
||||
work.definitionApprovalVersion &&
|
||||
work.designVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.approveDesign(
|
||||
work._id,
|
||||
work.definitionApprovalVersion as number,
|
||||
work.designVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve design
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Build
|
||||
</p>
|
||||
{latestRun ? (
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
Run {latestRun.status}:{" "}
|
||||
{latestRun.terminalSummary ??
|
||||
latestRun.terminalClassification ??
|
||||
"activity is still arriving"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[#747168]">No simulation Run yet.</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "ready" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void slice.startSimulation(
|
||||
work._id,
|
||||
"success",
|
||||
design?.slices?.[0]?.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<Play className="size-3.5" /> Simulate
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.status === "running" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void slice.cancelSimulation(latestRun._id)}
|
||||
>
|
||||
<X className="size-3.5" /> Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.status === "terminal" &&
|
||||
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void slice.retrySimulation(latestRun._id)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationLoading = () => (
|
||||
<output
|
||||
aria-label="Loading conversation"
|
||||
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||
>
|
||||
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="sr-only">Loading conversation…</span>
|
||||
</output>
|
||||
);
|
||||
|
||||
const ConversationEmptyState = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem. Casual conversation stays conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
type SliceOneState = ReturnType<typeof useSliceOne>;
|
||||
|
||||
const ProjectsLoading = () => (
|
||||
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||
<form
|
||||
className="w-full max-w-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void slice.connectRepository();
|
||||
}}
|
||||
>
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||
Slice 1 turns actionable conversation into proposed Work with exact
|
||||
provenance.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Public Git repository URL"
|
||||
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => slice.setRepository(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
required
|
||||
value={slice.repository}
|
||||
/>
|
||||
{slice.error ? (
|
||||
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 h-12 w-full"
|
||||
disabled={slice.pending}
|
||||
type="submit"
|
||||
>
|
||||
{slice.pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{slice.pending ? "Connecting" : "Connect project"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
export const SliceOnePage = () => {
|
||||
const slice = useSliceOne();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
const [draft, setDraft] = useState("");
|
||||
const attachments = useChatImages();
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const works = slice.works ?? EMPTY_WORKS;
|
||||
const workById = useMemo(
|
||||
() => new Map(works.map((work) => [String(work._id), work])),
|
||||
[works]
|
||||
);
|
||||
const notices = useMemo(() => projectWorkNotices(works), [works]);
|
||||
const timeline = useMemo(
|
||||
() => buildSliceOneTimeline(slice.agent.messages, notices),
|
||||
[notices, slice.agent.messages]
|
||||
);
|
||||
const busy =
|
||||
slice.agent.status === "submitted" || slice.agent.status === "streaming";
|
||||
|
||||
const revealSourceMessage = (rawText: string) => {
|
||||
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setHighlightedMessageId(messageId);
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
highlightTimer.current = setTimeout(
|
||||
() => setHighlightedMessageId(undefined),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
if (slice.projects === undefined) {
|
||||
return <ProjectsLoading />;
|
||||
}
|
||||
|
||||
if (!slice.selectedProject) {
|
||||
return <ConnectProject slice={slice} />;
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
await slice.agent.sendMessage(message, {
|
||||
images: attachments.images.map((image) => image.file),
|
||||
});
|
||||
setDraft("");
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||
style={viewportStyle}
|
||||
>
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<select
|
||||
aria-label="Current project"
|
||||
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||
onChange={(event) => slice.selectProject(event.target.value)}
|
||||
value={slice.selectedProject.id}
|
||||
>
|
||||
{slice.projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] uppercase text-[#858277]">
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{slice.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
</header>
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||
{!slice.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationLoading />
|
||||
) : null}
|
||||
{slice.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationEmptyState />
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
const work = workById.get(item.notice.workId);
|
||||
return work ? (
|
||||
<div
|
||||
className="chat-message ml-7"
|
||||
key={`notice-${item.notice.eventId}`}
|
||||
>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Work proposed from this conversation
|
||||
</p>
|
||||
<WorkCard
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm transition-colors duration-300 ${
|
||||
highlightedMessageId === item.message.id
|
||||
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
|
||||
: ""
|
||||
}`}
|
||||
id={`slice-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slice.agent.status === "submitted" ? (
|
||||
<ChatThinkingResponse />
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{attachments.images.length > 0 ? (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<PendingChatAttachments
|
||||
images={attachments.images}
|
||||
onRemove={attachments.handleRemove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{slice.agent.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{slice.agent.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{attachments.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{attachments.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||
<input
|
||||
accept="image/*"
|
||||
aria-label="Attach images"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
attachments.addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Attach images"
|
||||
className="size-11 shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
aria-label="Message Zopu"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim() || busy}
|
||||
onClick={() => void send()}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||
<p className="mb-4 text-xs text-[#747168]">
|
||||
{works.length} durable outcomes
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{drawerOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="absolute inset-0"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="grid size-9 place-items-center"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{works.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-[#747168]">
|
||||
Actionable messages will appear here.
|
||||
</p>
|
||||
) : null}
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
slice={slice}
|
||||
work={work}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ConversationComposer = ({
|
||||
draft,
|
||||
onDraftChange,
|
||||
workspace,
|
||||
}: {
|
||||
readonly draft: string;
|
||||
readonly onDraftChange: (draft: string) => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const attachments = useChatImages();
|
||||
const busy =
|
||||
workspace.agent.status === "submitted" ||
|
||||
workspace.agent.status === "streaming";
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
await workspace.agent.sendMessage(message, {
|
||||
images: attachments.images.map((image) => image.file),
|
||||
});
|
||||
onDraftChange("");
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
{attachments.images.length > 0 ? (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<PendingChatAttachments
|
||||
images={attachments.images}
|
||||
onRemove={attachments.handleRemove}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{workspace.agent.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{workspace.agent.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{attachments.error ? (
|
||||
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||
{attachments.error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||
<input
|
||||
accept="image/*"
|
||||
aria-label="Attach images"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
attachments.addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-label="Attach images"
|
||||
className="size-11 shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
aria-label="Message Zopu"
|
||||
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
|
||||
disabled={busy}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe an outcome or problem…"
|
||||
rows={1}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className="size-11 shrink-0"
|
||||
disabled={!draft.trim() || busy}
|
||||
onClick={() => void send()}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { WorkCard } from "./work-card";
|
||||
|
||||
const ConversationLoading = () => (
|
||||
<output
|
||||
aria-label="Loading conversation"
|
||||
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||
>
|
||||
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="sr-only">Loading conversation…</span>
|
||||
</output>
|
||||
);
|
||||
|
||||
const ConversationEmptyState = () => (
|
||||
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||
<div>
|
||||
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||
Describe an outcome or problem. Casual conversation stays conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationFeed = ({
|
||||
highlightedMessageId,
|
||||
onSourceSelect,
|
||||
workspace,
|
||||
}: {
|
||||
readonly highlightedMessageId?: string;
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const works = workspace.works ?? [];
|
||||
const workById = new Map(
|
||||
works.map((work) => [String(work._id), work] as const)
|
||||
);
|
||||
const notices = projectWorkNotices(works);
|
||||
const timeline = useMemo(
|
||||
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
|
||||
[notices, workspace.agent.messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||
{!workspace.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationLoading />
|
||||
) : null}
|
||||
{workspace.agent.historyReady && timeline.length === 0 ? (
|
||||
<ConversationEmptyState />
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
const work = workById.get(item.notice.workId) as
|
||||
| WorkRecord
|
||||
| undefined;
|
||||
return work ? (
|
||||
<div
|
||||
className="chat-message ml-7"
|
||||
key={`notice-${item.notice.eventId}`}
|
||||
>
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Work proposed from this conversation
|
||||
</p>
|
||||
<div className="rounded-sm">
|
||||
<span className="sr-only">Proposed Work</span>
|
||||
<WorkCard
|
||||
onSourceSelect={onSourceSelect}
|
||||
work={work}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm transition-colors duration-300 ${highlightedMessageId === item.message.id ? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]" : ""}`}
|
||||
id={`workspace-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{workspace.agent.status === "submitted" ? (
|
||||
<ChatThinkingResponse />
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { FolderGit2, LoaderCircle } from "lucide-react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
export const ProjectConnectForm = ({
|
||||
workspace,
|
||||
}: {
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||
<form
|
||||
className="w-full max-w-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void workspace.connectRepository();
|
||||
}}
|
||||
>
|
||||
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||
<FolderGit2 className="size-5" />
|
||||
</span>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Connect a project</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||
Turn actionable project conversation into proposed Work with exact
|
||||
provenance.
|
||||
</p>
|
||||
<input
|
||||
aria-label="Public Git repository URL"
|
||||
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||
onChange={(event) => workspace.setRepository(event.target.value)}
|
||||
placeholder="https://github.com/owner/repository"
|
||||
required
|
||||
value={workspace.repository}
|
||||
/>
|
||||
{workspace.error ? (
|
||||
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 h-12 w-full"
|
||||
disabled={workspace.pending}
|
||||
type="submit"
|
||||
>
|
||||
{workspace.pending ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{workspace.pending ? "Connecting" : "Connect project"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
63
apps/web/src/components/workspace/project-header.tsx
Normal file
63
apps/web/src/components/workspace/project-header.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { ProjectSettingsPanel } from "./project-settings-panel";
|
||||
|
||||
export const ProjectHeader = ({
|
||||
onOpenDrawer,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onOpenDrawer: () => void;
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const works = workspace.works ?? [];
|
||||
|
||||
return (
|
||||
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<select
|
||||
aria-label="Current project"
|
||||
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||
onChange={(event) => workspace.selectProject(event.target.value)}
|
||||
value={workspace.selectedProject?.id ?? ""}
|
||||
>
|
||||
{(workspace.projects ?? []).map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] uppercase text-[#858277]">
|
||||
Conversation to proposed Work
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Project settings"
|
||||
className="mr-2 size-9"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||
onClick={onOpenDrawer}
|
||||
type="button"
|
||||
>
|
||||
<Menu className="size-4" /> Work{" "}
|
||||
{workspace.works === undefined ? "…" : works.length}
|
||||
</button>
|
||||
{settingsOpen ? (
|
||||
<ProjectSettingsPanel
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
workspace={workspace}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
100
apps/web/src/components/workspace/project-settings-panel.tsx
Normal file
100
apps/web/src/components/workspace/project-settings-panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
|
||||
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
|
||||
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
|
||||
|
||||
import { ConversationComposer } from "./conversation-composer";
|
||||
import { ConversationFeed } from "./conversation-feed";
|
||||
import { ProjectConnectForm } from "./project-connect-form";
|
||||
import { ProjectHeader } from "./project-header";
|
||||
import { WorkFeed } from "./work-feed";
|
||||
|
||||
const ProjectLoading = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
|
||||
</main>
|
||||
);
|
||||
|
||||
export const ProjectWorkspacePage = () => {
|
||||
const workspace = useProjectWorkspace();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
if (workspace.projects === undefined) {
|
||||
return <ProjectLoading />;
|
||||
}
|
||||
if (!workspace.selectedProject) {
|
||||
return <ProjectConnectForm workspace={workspace} />;
|
||||
}
|
||||
|
||||
const works = workspace.works ?? [];
|
||||
const revealSourceMessage = (rawText: string) => {
|
||||
const messageId = findSourceMessageTarget(
|
||||
workspace.agent.messages,
|
||||
rawText
|
||||
);
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setHighlightedMessageId(messageId);
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
);
|
||||
highlightTimer.current = setTimeout(
|
||||
() => setHighlightedMessageId(undefined),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="workspace-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||
style={viewportStyle}
|
||||
>
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
<ProjectHeader
|
||||
onOpenDrawer={() => setDrawerOpen(true)}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<ConversationFeed
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
onSourceSelect={revealSourceMessage}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<ConversationComposer
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</section>
|
||||
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||
<p className="mb-4 text-xs text-[#747168]">
|
||||
{works.length} durable outcomes
|
||||
</p>
|
||||
<WorkFeed
|
||||
onSourceSelect={revealSourceMessage}
|
||||
works={works}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</aside>
|
||||
{drawerOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="absolute inset-0"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||
<button
|
||||
aria-label="Close Work drawer"
|
||||
className="grid size-9 place-items-center"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<WorkFeed
|
||||
onSourceSelect={revealSourceMessage}
|
||||
works={works}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
395
apps/web/src/components/workspace/work-card.tsx
Normal file
395
apps/web/src/components/workspace/work-card.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
Hammer,
|
||||
Play,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { changedFilesFor } from "@/lib/workspace/types";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
interface WorkCardProps {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly work: WorkRecord;
|
||||
readonly workspace: WorkspaceState;
|
||||
}
|
||||
|
||||
const starterDefinition = (work: WorkRecord) => ({
|
||||
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
||||
affectedUsers: ["Project users"],
|
||||
assumptions: [],
|
||||
constraints: [],
|
||||
desiredOutcome: work.objective,
|
||||
inScope: [work.objective],
|
||||
outOfScope: ["Unrelated product changes"],
|
||||
problem: work.objective,
|
||||
questions: [],
|
||||
requiredArtifacts: ["Simulation activity and terminal outcome"],
|
||||
risk: "medium",
|
||||
});
|
||||
|
||||
const starterDesign = (work: WorkRecord) => ({
|
||||
architectureSummary:
|
||||
"Validate the approved Definition, then exercise one deterministic fake slice.",
|
||||
callFlowDelta: [
|
||||
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
|
||||
],
|
||||
concerns: [],
|
||||
evidenceRequirements: ["Terminal Run classification"],
|
||||
fileTreeDelta: [],
|
||||
impactMap: {
|
||||
files: [],
|
||||
modules: [],
|
||||
risks: [],
|
||||
summary: "Compact vertical-slice simulation",
|
||||
},
|
||||
invariants: [
|
||||
"Simulation never claims implementation",
|
||||
"Every Attempt reaches a terminal classification",
|
||||
],
|
||||
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
|
||||
slices: [
|
||||
{
|
||||
codeBoundaries: ["workExecution"],
|
||||
dependsOn: [],
|
||||
evidenceRequirements: ["Normalized activity events"],
|
||||
id: "deterministic-simulation",
|
||||
objective: work.objective,
|
||||
observableBehavior: "A terminal fake Run is visible",
|
||||
reviewRequired: false,
|
||||
title: "Deterministic simulation",
|
||||
verification: ["Run completes with a terminal classification"],
|
||||
},
|
||||
],
|
||||
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
|
||||
export const WorkCard = ({
|
||||
onSourceSelect,
|
||||
work,
|
||||
workspace,
|
||||
}: WorkCardProps) => {
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||
const [latestRun] = work.runs;
|
||||
const openQuestions =
|
||||
work.definition?.questions?.filter((question) => question.status === "open")
|
||||
.length ?? 0;
|
||||
|
||||
return (
|
||||
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||
Proposed Work
|
||||
</p>
|
||||
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||
{work.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||
{work.objective}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||
onClick={() => setSourcesOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{sources.length} exact source{" "}
|
||||
{sources.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{sourcesOpen ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||
key={source.messageId}
|
||||
onClick={() => onSourceSelect(source.rawText)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Outcome
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
||||
<p className="mt-2 text-[#747168]">
|
||||
Risk: {work.definition?.risk ?? "not defined"}
|
||||
</p>
|
||||
{openQuestions > 0 ? (
|
||||
<p className="mt-1 text-amber-800">
|
||||
{openQuestions} open question(s)
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "proposed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void workspace.requestDefinition(work._id)}
|
||||
>
|
||||
<Sparkles className="size-3.5" /> Define
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "defining" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.saveDefinition(
|
||||
work._id,
|
||||
starterDefinition(work)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save definition
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-definition-approval" &&
|
||||
work.definitionVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void workspace.approveDefinition(
|
||||
work._id,
|
||||
work.definitionVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Design
|
||||
</p>
|
||||
<p className="mt-1 leading-5 text-[#626057]">
|
||||
{work.design?.architectureSummary ?? "No Design Packet yet."}
|
||||
</p>
|
||||
{work.design?.slices?.map((item) => (
|
||||
<p className="mt-1 text-[#747168]" key={item.id}>
|
||||
{item.title}: {item.observableBehavior}
|
||||
</p>
|
||||
))}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "designing" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.saveDesign(work._id, starterDesign(work))
|
||||
}
|
||||
>
|
||||
<Hammer className="size-3.5" /> Save design
|
||||
</Button>
|
||||
) : null}
|
||||
{work.status === "awaiting-design-approval" &&
|
||||
work.definitionApprovalVersion &&
|
||||
work.designVersion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void workspace.approveDesign(
|
||||
work._id,
|
||||
work.definitionApprovalVersion as number,
|
||||
work.designVersion as number
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="size-3.5" /> Approve design
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||
Build
|
||||
</p>
|
||||
{workspace.operationError ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 leading-5">
|
||||
{workspace.operationError.message}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Dismiss error"
|
||||
className="shrink-0"
|
||||
onClick={() => workspace.clearOperationError()}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{latestRun ? (
|
||||
<div className="mt-1 space-y-2 text-[#626057]">
|
||||
<p className="leading-5">
|
||||
{latestRun.executionKind === "real"
|
||||
? "AgentOS"
|
||||
: "Simulation"}{" "}
|
||||
run {latestRun.status}:{" "}
|
||||
{latestRun.terminalSummary ??
|
||||
latestRun.terminalClassification ??
|
||||
"activity is still arriving"}
|
||||
</p>
|
||||
{latestRun.baseRevision ? (
|
||||
<p className="font-mono text-[10px] text-[#747168]">
|
||||
{latestRun.baseRevision.slice(0, 8)} →{" "}
|
||||
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
|
||||
</p>
|
||||
) : null}
|
||||
{latestRun.artifacts?.map((artifact) => (
|
||||
<div key={artifact._id} className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{artifact.uri ? (
|
||||
<a
|
||||
className="underline"
|
||||
href={artifact.uri}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{artifact.title}
|
||||
</a>
|
||||
) : (
|
||||
artifact.title
|
||||
)}
|
||||
</p>
|
||||
{changedFilesFor(artifact).length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{changedFilesFor(artifact).map((file) => (
|
||||
<li
|
||||
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
|
||||
key={file}
|
||||
>
|
||||
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
|
||||
<span className="min-w-0 break-all">{file}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{latestRun.attemptEvents &&
|
||||
latestRun.attemptEvents.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{(logsOpen
|
||||
? latestRun.attemptEvents
|
||||
: latestRun.attemptEvents.slice(-3)
|
||||
).map((item) => (
|
||||
<p
|
||||
className="border-l-2 border-[#b8c760] pl-2 leading-5"
|
||||
key={item._id}
|
||||
>
|
||||
{item.message}
|
||||
</p>
|
||||
))}
|
||||
{latestRun.attemptEvents.length > 3 ? (
|
||||
<button
|
||||
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
|
||||
onClick={() => setLogsOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<ScrollText className="size-3.5" />
|
||||
{logsOpen
|
||||
? "Show recent activity"
|
||||
: `Show full activity log (${latestRun.attemptEvents.length})`}
|
||||
<ChevronRight
|
||||
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{work.status === "ready" ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!workspace.projectGitConnection}
|
||||
onClick={() => void workspace.startExecution(work._id)}
|
||||
>
|
||||
<Play className="size-3.5" /> Run
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void workspace.startSimulation(work._id, "success")
|
||||
}
|
||||
>
|
||||
<Play className="size-3.5" /> Simulate
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{latestRun?.status === "running" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void (latestRun.executionKind === "real"
|
||||
? workspace.cancelExecution(latestRun._id)
|
||||
: workspace.cancelSimulation(latestRun._id))
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" /> Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{latestRun?.executionKind !== "real" &&
|
||||
latestRun?.status === "terminal" &&
|
||||
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void workspace.retrySimulation(latestRun._id)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" /> Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
import { WorkCard } from "./work-card";
|
||||
|
||||
export const WorkFeed = ({
|
||||
onSourceSelect,
|
||||
works,
|
||||
workspace,
|
||||
}: {
|
||||
readonly onSourceSelect: (rawText: string) => void;
|
||||
readonly works: readonly WorkRecord[];
|
||||
readonly workspace: WorkspaceState;
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
{works.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-[#747168]">
|
||||
Actionable messages will appear here.
|
||||
</p>
|
||||
) : null}
|
||||
{works.map((work) => (
|
||||
<WorkCard
|
||||
key={work._id}
|
||||
onSourceSelect={onSourceSelect}
|
||||
work={work}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -55,7 +55,12 @@ export const useOrganizationChatAgent = (
|
||||
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
let status: AgentStatus = projected.pending ? "submitted" : "idle";
|
||||
let status: AgentStatus = "idle";
|
||||
if (projected.streaming) {
|
||||
status = "streaming";
|
||||
} else if (projected.pending) {
|
||||
status = "submitted";
|
||||
}
|
||||
if (!organizationId || rows === undefined) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
} else if (projected.failedError || sendError) {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
interface WorkRecord {
|
||||
readonly _id: Id<"works">;
|
||||
readonly title: string;
|
||||
readonly objective: string;
|
||||
readonly status: string;
|
||||
readonly definitionVersion?: number;
|
||||
readonly definitionApprovalVersion?: number;
|
||||
readonly designVersion?: number;
|
||||
readonly designApprovalVersion?: number;
|
||||
readonly signals: readonly {
|
||||
sources: readonly { messageId: string; rawText: string }[];
|
||||
}[];
|
||||
readonly events: readonly { _id: string; createdAt: number; kind: string }[];
|
||||
readonly definitions: readonly unknown[];
|
||||
readonly designs: readonly unknown[];
|
||||
readonly slices: readonly unknown[];
|
||||
readonly runs: readonly {
|
||||
_id: Id<"workRuns">;
|
||||
status: string;
|
||||
terminalClassification?: string;
|
||||
terminalSummary?: string;
|
||||
}[];
|
||||
readonly definition: {
|
||||
risk?: string;
|
||||
questions?: { status: string }[];
|
||||
} | null;
|
||||
readonly design: {
|
||||
architectureSummary?: string;
|
||||
slices?: { id: string; title: string; observableBehavior: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
const workListRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
WorkRecord[]
|
||||
>("workPlanning:listForProject");
|
||||
const requestDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works"> },
|
||||
unknown
|
||||
>("workPlanning:requestDefinition");
|
||||
const saveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDefinitionProposal");
|
||||
const approveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; version: number },
|
||||
unknown
|
||||
>("workPlanning:approveDefinition");
|
||||
const saveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDesignProposal");
|
||||
const approveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
|
||||
unknown
|
||||
>("workPlanning:approveDesign");
|
||||
const startSimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
workId: Id<"works">;
|
||||
scenario:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled";
|
||||
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");
|
||||
|
||||
export const useSliceOne = () => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
||||
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
||||
const approveDefinitionMutation = useMutation(approveDefinitionRef);
|
||||
const saveDesignMutation = useMutation(saveDesignRef);
|
||||
const approveDesignMutation = useMutation(approveDesignRef);
|
||||
const startSimulationMutation = useMutation(startSimulationRef);
|
||||
const cancelSimulationMutation = useMutation(cancelSimulationRef);
|
||||
const retrySimulationMutation = useMutation(retrySimulationRef);
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null,
|
||||
[activeProjectId, projects]
|
||||
);
|
||||
|
||||
const connectRepository = async () => {
|
||||
const repositoryUrl = repository.trim();
|
||||
if (!repositoryUrl || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl });
|
||||
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(toError(caughtError));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProject = (projectId: string) => {
|
||||
setSelectedProjectId(projectId as unknown as Id<"projects">);
|
||||
};
|
||||
|
||||
const requestDefinition = (workId: Id<"works">) =>
|
||||
requestDefinitionMutation({ workId });
|
||||
const saveDefinition = (workId: Id<"works">, payload: unknown) =>
|
||||
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId });
|
||||
const approveDefinition = (workId: Id<"works">, version: number) =>
|
||||
approveDefinitionMutation({ version, workId });
|
||||
const saveDesign = (workId: Id<"works">, payload: unknown) =>
|
||||
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId });
|
||||
const approveDesign = (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId });
|
||||
const startSimulation = (
|
||||
workId: Id<"works">,
|
||||
scenario:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled",
|
||||
sliceId?: string
|
||||
) => startSimulationMutation({ scenario, sliceId, workId });
|
||||
const cancelSimulation = (runId: Id<"workRuns">) =>
|
||||
cancelSimulationMutation({ runId });
|
||||
const retrySimulation = (runId: Id<"workRuns">) =>
|
||||
retrySimulationMutation({ runId });
|
||||
|
||||
return {
|
||||
agent,
|
||||
approveDefinition,
|
||||
approveDesign,
|
||||
cancelSimulation,
|
||||
connectRepository,
|
||||
error,
|
||||
pending,
|
||||
projects,
|
||||
repository,
|
||||
requestDefinition,
|
||||
retrySimulation,
|
||||
saveDefinition,
|
||||
saveDesign,
|
||||
selectProject,
|
||||
selectedProject,
|
||||
setRepository,
|
||||
startSimulation,
|
||||
works,
|
||||
} as const;
|
||||
};
|
||||
276
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
276
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
type ExecutionScenario =
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled";
|
||||
|
||||
const workListRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
WorkRecord[]
|
||||
>("workPlanning:listForProject");
|
||||
const requestDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works"> },
|
||||
unknown
|
||||
>("workPlanning:requestDefinition");
|
||||
const saveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDefinitionProposal");
|
||||
const approveDefinitionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; version: number },
|
||||
unknown
|
||||
>("workPlanning:approveDefinition");
|
||||
const saveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; payloadJson: string },
|
||||
unknown
|
||||
>("workPlanning:saveDesignProposal");
|
||||
const approveDesignRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; definitionVersion: number; designVersion: number },
|
||||
unknown
|
||||
>("workPlanning:approveDesign");
|
||||
const startSimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
|
||||
unknown
|
||||
>("workExecution:startSimulatedExecution");
|
||||
const cancelSimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecution:cancelSimulatedExecution");
|
||||
const retrySimulationRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecution:retrySimulatedExecution");
|
||||
const startExecutionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ workId: Id<"works">; sliceId?: string },
|
||||
unknown
|
||||
>("workExecutionWorkflow:startExecution");
|
||||
const cancelExecutionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runId: Id<"workRuns"> },
|
||||
unknown
|
||||
>("workExecutionWorkflow:cancelExecution");
|
||||
const listGitConnectionsRef = makeFunctionReference<
|
||||
"query",
|
||||
Record<string, never>,
|
||||
readonly {
|
||||
id: string;
|
||||
provider: "github" | "gitea";
|
||||
serverUrl: string;
|
||||
username?: string;
|
||||
}[]
|
||||
>("gitConnectionData:list");
|
||||
const projectGitConnectionRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects"> },
|
||||
{
|
||||
id: string;
|
||||
provider: "github" | "gitea";
|
||||
serverUrl: string;
|
||||
username?: string;
|
||||
} | null
|
||||
>("gitConnectionData:getForProject");
|
||||
const connectGiteaRef = makeFunctionReference<
|
||||
"action",
|
||||
{ serverUrl: string; token: string; username?: string },
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGitea");
|
||||
const connectGithubRef = makeFunctionReference<
|
||||
"action",
|
||||
Record<string, never>,
|
||||
{ connectionId: Id<"gitConnections"> }
|
||||
>("gitConnections:connectGithub");
|
||||
const attachGitConnectionRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
|
||||
unknown
|
||||
>("gitConnectionData:attachToProject");
|
||||
|
||||
export const useProjectWorkspace = (): WorkspaceState => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [operationError, setOperationError] = useState<Error>();
|
||||
|
||||
const selectedProjectStillExists = projects?.some(
|
||||
(project) => project.id === (selectedProjectId as unknown as string)
|
||||
);
|
||||
const activeProjectId = selectedProjectStillExists
|
||||
? selectedProjectId
|
||||
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||
const works = useQuery(
|
||||
workListRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const gitConnections = useQuery(
|
||||
listGitConnectionsRef,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const projectGitConnection = useQuery(
|
||||
projectGitConnectionRef,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
||||
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
||||
const approveDefinitionMutation = useMutation(approveDefinitionRef);
|
||||
const saveDesignMutation = useMutation(saveDesignRef);
|
||||
const approveDesignMutation = useMutation(approveDesignRef);
|
||||
const startSimulationMutation = useMutation(startSimulationRef);
|
||||
const cancelSimulationMutation = useMutation(cancelSimulationRef);
|
||||
const retrySimulationMutation = useMutation(retrySimulationRef);
|
||||
const startExecutionMutation = useMutation(startExecutionRef);
|
||||
const cancelExecutionMutation = useMutation(cancelExecutionRef);
|
||||
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
|
||||
const connectGiteaAction = useAction(connectGiteaRef);
|
||||
const connectGithubAction = useAction(connectGithubRef);
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null,
|
||||
[activeProjectId, projects]
|
||||
);
|
||||
|
||||
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
return await operation();
|
||||
} catch (caughtError) {
|
||||
setOperationError(toError(caughtError));
|
||||
throw caughtError;
|
||||
}
|
||||
};
|
||||
|
||||
const connectRepository = async () => {
|
||||
const repositoryUrl = repository.trim();
|
||||
if (!repositoryUrl || pending) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const project = await importPublicGit({ repositoryUrl });
|
||||
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(toError(caughtError));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const attachConnection = async (connectionId: Id<"gitConnections">) => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error("Select a project first");
|
||||
}
|
||||
await attachGitConnectionMutation({
|
||||
connectionId,
|
||||
projectId: activeProjectId,
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitea = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
username?: string;
|
||||
}) =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGiteaAction(input);
|
||||
await attachConnection(result.connectionId);
|
||||
});
|
||||
|
||||
const connectLinkedGithub = () =>
|
||||
runOperation(async () => {
|
||||
const result = await connectGithubAction({});
|
||||
await attachConnection(result.connectionId);
|
||||
});
|
||||
|
||||
return {
|
||||
agent,
|
||||
approveDefinition: (workId: Id<"works">, version: number) =>
|
||||
approveDefinitionMutation({ version, workId }),
|
||||
approveDesign: (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
|
||||
authorizeGithub: () =>
|
||||
authClient.signIn.social({
|
||||
callbackURL: window.location.href,
|
||||
provider: "github",
|
||||
}),
|
||||
cancelExecution: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => cancelExecutionMutation({ runId })),
|
||||
cancelSimulation: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => cancelSimulationMutation({ runId })),
|
||||
clearOperationError: () => setOperationError(undefined),
|
||||
connectGitea,
|
||||
connectLinkedGithub,
|
||||
connectRepository,
|
||||
error,
|
||||
gitConnections,
|
||||
operationError,
|
||||
pending,
|
||||
projectGitConnection,
|
||||
projects,
|
||||
repository,
|
||||
requestDefinition: (workId: Id<"works">) =>
|
||||
requestDefinitionMutation({ workId }),
|
||||
retrySimulation: (runId: Id<"workRuns">) =>
|
||||
runOperation(() => retrySimulationMutation({ runId })),
|
||||
saveDefinition: (workId: Id<"works">, payload: unknown) =>
|
||||
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||
saveDesign: (workId: Id<"works">, payload: unknown) =>
|
||||
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||
selectProject: (projectId: string) =>
|
||||
setSelectedProjectId(projectId as unknown as Id<"projects">),
|
||||
selectedProject: selectedProject
|
||||
? { id: selectedProject.id, name: selectedProject.name }
|
||||
: null,
|
||||
setRepository,
|
||||
startExecution: (workId: Id<"works">, sliceId?: string) =>
|
||||
runOperation(() => startExecutionMutation({ sliceId, workId })),
|
||||
startSimulation: (
|
||||
workId: Id<"works">,
|
||||
scenario: ExecutionScenario,
|
||||
sliceId?: string
|
||||
) =>
|
||||
runOperation(() =>
|
||||
startSimulationMutation({ scenario, sliceId, workId })
|
||||
),
|
||||
works,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { visualViewportStyle } from "./use-visual-viewport";
|
||||
|
||||
describe("Slice 1 visual viewport", () => {
|
||||
describe("Workspace visual viewport", () => {
|
||||
test("shrinks the application surface to the keyboard-visible height", () => {
|
||||
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
|
||||
height: "500px",
|
||||
@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
||||
});
|
||||
};
|
||||
|
||||
root.classList.add("slice-one-viewport-lock");
|
||||
root.classList.add("workspace-viewport-lock");
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
viewport?.addEventListener("resize", update);
|
||||
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
root.classList.remove("slice-one-viewport-lock");
|
||||
root.classList.remove("workspace-viewport-lock");
|
||||
window.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("resize", update);
|
||||
viewport?.removeEventListener("scroll", update);
|
||||
@@ -6,8 +6,8 @@ body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html.slice-one-viewport-lock,
|
||||
html.slice-one-viewport-lock body {
|
||||
html.workspace-viewport-lock,
|
||||
html.workspace-viewport-lock body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
@@ -116,9 +116,42 @@ html.slice-one-viewport-lock body {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.slice-one-surface .chat-markdown,
|
||||
.slice-one-surface .chat-reasoning,
|
||||
.slice-one-surface .thinking-line {
|
||||
/*
|
||||
* The chat always renders on the light workspace surface
|
||||
* (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
|
||||
* app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
|
||||
* the rules below read theme tokens, which under `.dark` resolve to near-black
|
||||
* values — producing unreadable dark-on-dark blocks. Re-declare the relevant
|
||||
* tokens to warm-light values inside the markdown context so every token-driven
|
||||
* chrome (headers, copy/download/fullscreen controls, table borders/cells,
|
||||
* inline code) reads correctly on the light surface without touching the
|
||||
* global theme or unrelated UI.
|
||||
*/
|
||||
.workspace-surface .chat-markdown {
|
||||
--background: #ffffff;
|
||||
--foreground: #232321;
|
||||
--muted: #f4f4f2;
|
||||
--muted-foreground: #69675f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #232321;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #232321;
|
||||
--secondary: #f4f4f2;
|
||||
--secondary-foreground: #232321;
|
||||
--accent: #efece4;
|
||||
--accent-foreground: #232321;
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #232321;
|
||||
--sidebar-accent: #f4f4f2;
|
||||
--sidebar-accent-foreground: #232321;
|
||||
--border: #e2e0d6;
|
||||
--input: #e2e0d6;
|
||||
--ring: #b6b7b4;
|
||||
}
|
||||
|
||||
.workspace-surface .chat-markdown,
|
||||
.workspace-surface .chat-reasoning,
|
||||
.workspace-surface .thinking-line {
|
||||
color: #232321;
|
||||
}
|
||||
|
||||
@@ -223,6 +256,16 @@ html.slice-one-viewport-lock body {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Readable header band + zebra rows so tables stand out on the surface. */
|
||||
.chat-markdown thead th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-markdown tbody tr:nth-child(even) {
|
||||
background: color-mix(in oklch, var(--muted) 55%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-markdown *,
|
||||
.chat-message,
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
interface AuthLoaderData {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
|
||||
|
||||
export const loadAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
@@ -14,9 +10,12 @@ export const loadAuthToken = async (
|
||||
if (!cookie) {
|
||||
return { token: null };
|
||||
}
|
||||
const tokenUrl = new URL(
|
||||
"/api/auth/convex/token",
|
||||
new URL(request.url).origin
|
||||
);
|
||||
|
||||
const headers = new Headers({ cookie });
|
||||
headers.set("host", tokenUrl.host);
|
||||
const response = await fetch(tokenUrl, { headers });
|
||||
if (response.status === 401) {
|
||||
return { token: null };
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("projectConversation", () => {
|
||||
messageId: "user-1",
|
||||
rawText: "Build it",
|
||||
role: "user",
|
||||
status: "processing",
|
||||
status: "dispatching",
|
||||
}),
|
||||
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
|
||||
]);
|
||||
@@ -28,6 +28,21 @@ describe("projectConversation", () => {
|
||||
expect(state.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("projects partial assistant text as streaming", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "assistant-streaming",
|
||||
rawText: "Working",
|
||||
role: "assistant",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
expect(state.streaming).toBe(true);
|
||||
expect(state.messages[0]?.parts).toEqual([
|
||||
{ state: "streaming", text: "Working", type: "text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("projects completed Convex rows into renderable messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
|
||||
@@ -11,15 +11,35 @@ export interface ConversationRow {
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly role: "assistant" | "user";
|
||||
readonly status: "completed" | "failed" | "processing" | "queued";
|
||||
readonly status:
|
||||
| "aborted"
|
||||
| "completed"
|
||||
| "dispatching"
|
||||
| "failed"
|
||||
| "queued"
|
||||
| "running";
|
||||
}
|
||||
|
||||
export const projectConversation = (rows: readonly ConversationRow[]) => ({
|
||||
export const projectConversation = (rows: readonly ConversationRow[]) => {
|
||||
const streaming = rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
row.status === "running" &&
|
||||
row.rawText.length > 0
|
||||
);
|
||||
return {
|
||||
failedError: rows.findLast(
|
||||
(row) => row.role === "assistant" && row.status === "failed"
|
||||
)?.error,
|
||||
messages: rows
|
||||
.filter((row) => row.role === "user" || row.status === "completed")
|
||||
.filter(
|
||||
(row) =>
|
||||
row.role === "user" ||
|
||||
row.status === "completed" ||
|
||||
(row.role === "assistant" &&
|
||||
row.status === "running" &&
|
||||
row.rawText.length > 0)
|
||||
)
|
||||
.map<ConversationMessage>((row) => ({
|
||||
id: row.messageId,
|
||||
parts: [
|
||||
@@ -33,7 +53,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
|
||||
...(row.rawText
|
||||
? [
|
||||
{
|
||||
state: "done" as const,
|
||||
state:
|
||||
row.status === "running"
|
||||
? ("streaming" as const)
|
||||
: ("done" as const),
|
||||
text: row.rawText,
|
||||
type: "text" as const,
|
||||
},
|
||||
@@ -45,6 +68,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
|
||||
pending: rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
(row.status === "queued" || row.status === "processing")
|
||||
(row.status === "queued" ||
|
||||
row.status === "dispatching" ||
|
||||
row.status === "running")
|
||||
),
|
||||
});
|
||||
streaming,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
|
||||
const source = (relativePath: string) =>
|
||||
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
||||
|
||||
describe("Slice 1 frontend regression contracts", () => {
|
||||
describe("Workspace frontend regression contracts", () => {
|
||||
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
|
||||
const page = source("../../components/slice-one/slice-one-page.tsx");
|
||||
const page = source(
|
||||
"../../components/workspace/project-workspace-page.tsx"
|
||||
);
|
||||
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
|
||||
const root = source("../../root.tsx");
|
||||
const styles = source("../../index.css");
|
||||
|
||||
expect(root).toContain("interactive-widget=resizes-content");
|
||||
expect(page).toContain("style={viewportStyle}");
|
||||
expect(page).toContain("fixed inset-x-0 top-0");
|
||||
expect(page).not.toContain("slice-one-surface flex h-svh");
|
||||
expect(styles).toContain("html.slice-one-viewport-lock body");
|
||||
expect(viewport).toContain("workspace-viewport-lock");
|
||||
expect(styles).toContain("html.workspace-viewport-lock body");
|
||||
expect(styles).toContain("overflow: hidden");
|
||||
});
|
||||
|
||||
test("keeps the responsive shell shrinkable with a pinned composer", () => {
|
||||
const page = source("../../components/slice-one/slice-one-page.tsx");
|
||||
const page = source(
|
||||
"../../components/workspace/project-workspace-page.tsx"
|
||||
);
|
||||
const feed = source("../../components/workspace/conversation-feed.tsx");
|
||||
const composer = source(
|
||||
"../../components/workspace/conversation-composer.tsx"
|
||||
);
|
||||
|
||||
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
|
||||
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
|
||||
expect(page).toContain('className="shrink-0 border-t');
|
||||
expect(page).toContain(
|
||||
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
|
||||
expect(composer).toContain('className="shrink-0 border-t');
|
||||
expect(composer).toContain(
|
||||
'className="mx-auto flex max-w-2xl items-end gap-2"'
|
||||
);
|
||||
});
|
||||
@@ -50,4 +59,20 @@ describe("Slice 1 frontend regression contracts", () => {
|
||||
expect(messageRenderer).toContain("mermaid");
|
||||
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
|
||||
});
|
||||
|
||||
test("keeps markdown readable on the forced-dark workspace surface", () => {
|
||||
const styles = source("../../index.css");
|
||||
|
||||
// The workspace surface is light, but the app forces the dark theme, so
|
||||
// the markdown context must re-declare the theme tokens streamdown and the
|
||||
// table/code rules read from. Without these the dark tokens (~22-28%
|
||||
// lightness) produce near-black text, borders, and backgrounds.
|
||||
expect(styles).toContain(".workspace-surface .chat-markdown");
|
||||
expect(styles).toContain("--muted: #f4f4f2");
|
||||
expect(styles).toContain("--border: #e2e0d6");
|
||||
expect(styles).toContain("--sidebar: #ffffff");
|
||||
// Table headers and zebra rows must stand out on the surface.
|
||||
expect(styles).toContain(".chat-markdown thead th");
|
||||
expect(styles).toContain(".chat-markdown tbody tr:nth-child(even)");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
|
||||
import {
|
||||
buildWorkspaceTimeline,
|
||||
findSourceMessageTarget,
|
||||
} from "./presentation";
|
||||
|
||||
const textMessage = (
|
||||
id: string,
|
||||
@@ -23,9 +26,9 @@ const notice: WorkNotice = {
|
||||
workId: "work-1",
|
||||
};
|
||||
|
||||
describe("Slice 1 presentation", () => {
|
||||
describe("Workspace presentation", () => {
|
||||
test("places proposed Work after the assistant response to its source", () => {
|
||||
const timeline = buildSliceOneTimeline(
|
||||
const timeline = buildWorkspaceTimeline(
|
||||
[
|
||||
textMessage("user-1", "user", "Build the phone flow."),
|
||||
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
|
||||
@@ -7,14 +7,9 @@ import {
|
||||
} from "@/lib/chat/transforms";
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
export type SliceTimelineItem =
|
||||
| {
|
||||
readonly kind: "message";
|
||||
readonly message: ConversationMessage;
|
||||
}
|
||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||
import type { WorkspaceTimelineItem } from "./types";
|
||||
|
||||
export const isSliceOneVisibleMessage = (
|
||||
export const isVisibleConversationMessage = (
|
||||
message: ConversationMessage
|
||||
): boolean =>
|
||||
message.role === "user" ||
|
||||
@@ -42,11 +37,11 @@ const targetIndexForNotice = (
|
||||
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
||||
};
|
||||
|
||||
export const buildSliceOneTimeline = (
|
||||
export const buildWorkspaceTimeline = (
|
||||
allMessages: readonly ConversationMessage[],
|
||||
notices: readonly WorkNotice[]
|
||||
): readonly SliceTimelineItem[] => {
|
||||
const messages = allMessages.filter(isSliceOneVisibleMessage);
|
||||
): readonly WorkspaceTimelineItem[] => {
|
||||
const messages = allMessages.filter(isVisibleConversationMessage);
|
||||
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
|
||||
for (const notice of notices) {
|
||||
const targetIndex = targetIndexForNotice(messages, notice);
|
||||
@@ -55,7 +50,7 @@ export const buildSliceOneTimeline = (
|
||||
noticesByMessageIndex.set(targetIndex, atTarget);
|
||||
}
|
||||
|
||||
const timeline: SliceTimelineItem[] = [];
|
||||
const timeline: WorkspaceTimelineItem[] = [];
|
||||
for (const [index, message] of messages.entries()) {
|
||||
timeline.push({ kind: "message", message });
|
||||
for (const notice of noticesByMessageIndex.get(index) ?? []) {
|
||||
163
apps/web/src/lib/workspace/types.ts
Normal file
163
apps/web/src/lib/workspace/types.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
export interface WorkRecord {
|
||||
readonly _id: Id<"works">;
|
||||
readonly title: string;
|
||||
readonly objective: string;
|
||||
readonly status: string;
|
||||
readonly definitionVersion?: number;
|
||||
readonly definitionApprovalVersion?: number;
|
||||
readonly designVersion?: number;
|
||||
readonly signals: readonly {
|
||||
readonly sources: readonly { messageId: string; rawText: string }[];
|
||||
}[];
|
||||
readonly runs: readonly WorkRun[];
|
||||
readonly definition: {
|
||||
readonly risk?: string;
|
||||
readonly questions?: readonly { status: string }[];
|
||||
} | null;
|
||||
readonly design: {
|
||||
readonly architectureSummary?: string;
|
||||
readonly slices?: readonly {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly observableBehavior: string;
|
||||
}[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface WorkRun {
|
||||
readonly artifacts?: readonly WorkArtifact[];
|
||||
readonly attemptEvents?: readonly WorkAttemptEvent[];
|
||||
readonly baseRevision?: string;
|
||||
readonly candidateRevision?: string;
|
||||
readonly executionKind?: string;
|
||||
readonly _id: Id<"workRuns">;
|
||||
readonly status: string;
|
||||
readonly terminalClassification?: string;
|
||||
readonly terminalSummary?: string;
|
||||
}
|
||||
|
||||
export interface WorkArtifact {
|
||||
readonly _id: string;
|
||||
readonly metadataJson: string;
|
||||
readonly title: string;
|
||||
readonly uri?: string;
|
||||
}
|
||||
|
||||
export interface WorkAttemptEvent {
|
||||
readonly _id: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface GitConnection {
|
||||
readonly id: string;
|
||||
readonly provider: "github" | "gitea";
|
||||
readonly serverUrl: string;
|
||||
readonly username?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListItem {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
readonly agent: {
|
||||
readonly error?: Error;
|
||||
readonly historyReady: boolean;
|
||||
readonly messages: readonly ConversationMessage[];
|
||||
readonly sendMessage: (
|
||||
message: string,
|
||||
options?: { readonly images?: readonly File[] }
|
||||
) => Promise<void>;
|
||||
readonly status:
|
||||
| "connecting"
|
||||
| "error"
|
||||
| "idle"
|
||||
| "streaming"
|
||||
| "submitted";
|
||||
};
|
||||
readonly approveDefinition: (
|
||||
workId: Id<"works">,
|
||||
version: number
|
||||
) => Promise<unknown>;
|
||||
readonly approveDesign: (
|
||||
workId: Id<"works">,
|
||||
definitionVersion: number,
|
||||
designVersion: number
|
||||
) => Promise<unknown>;
|
||||
readonly authorizeGithub: () => Promise<unknown>;
|
||||
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly clearOperationError: () => void;
|
||||
readonly connectGitea: (input: {
|
||||
readonly serverUrl: string;
|
||||
readonly token: string;
|
||||
readonly username?: string;
|
||||
}) => Promise<void>;
|
||||
readonly connectLinkedGithub: () => Promise<void>;
|
||||
readonly connectRepository: () => Promise<void>;
|
||||
readonly error?: Error;
|
||||
readonly gitConnections: readonly GitConnection[] | undefined;
|
||||
readonly operationError?: Error;
|
||||
readonly pending: boolean;
|
||||
readonly projectGitConnection: GitConnection | null | undefined;
|
||||
readonly projects: readonly ProjectListItem[] | undefined;
|
||||
readonly repository: string;
|
||||
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
|
||||
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||
readonly saveDefinition: (
|
||||
workId: Id<"works">,
|
||||
payload: unknown
|
||||
) => Promise<unknown>;
|
||||
readonly saveDesign: (
|
||||
workId: Id<"works">,
|
||||
payload: unknown
|
||||
) => Promise<unknown>;
|
||||
readonly selectProject: (projectId: string) => void;
|
||||
readonly selectedProject: ProjectListItem | null;
|
||||
readonly setRepository: (value: string) => void;
|
||||
readonly startExecution: (
|
||||
workId: Id<"works">,
|
||||
sliceId?: string
|
||||
) => Promise<unknown>;
|
||||
readonly startSimulation: (
|
||||
workId: Id<"works">,
|
||||
scenario:
|
||||
| "success"
|
||||
| "transient-failure-then-success"
|
||||
| "needs-input"
|
||||
| "permanent-failure"
|
||||
| "cancelled",
|
||||
sliceId?: string
|
||||
) => Promise<unknown>;
|
||||
readonly works: readonly WorkRecord[] | undefined;
|
||||
}
|
||||
|
||||
export type WorkspaceTimelineItem =
|
||||
| { readonly kind: "message"; readonly message: ConversationMessage }
|
||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||
|
||||
export interface ArtifactMetadata {
|
||||
readonly changedFiles?: readonly string[];
|
||||
}
|
||||
|
||||
export const parseArtifactMetadata = (
|
||||
artifact: WorkArtifact
|
||||
): ArtifactMetadata => {
|
||||
if (!artifact.metadataJson) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
|
||||
parseArtifactMetadata(artifact).changedFiles ?? [];
|
||||
@@ -6,8 +6,5 @@ export default [
|
||||
route("login", "./routes/auth/login/page.tsx"),
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
index("./routes/app/mobile/page.tsx"),
|
||||
route("dashboard", "./routes/app/dashboard/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function Dashboard() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
export default function MobileLandingRedirect() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
|
||||
|
||||
export default function ProjectWorkspaceRoute() {
|
||||
return <ProjectWorkspacePage />;
|
||||
}
|
||||
@@ -13,5 +13,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
"/api/auth": {
|
||||
changeOrigin: true,
|
||||
target: "https://befitting-dalmatian-161.convex.site",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
###############################################################################
|
||||
# Zopu Runtime Deployment — Environment Template
|
||||
#
|
||||
# Copy to .env and fill in real values. Never commit .env to the repository.
|
||||
# This file documents every environment group required by the single-node
|
||||
# execution plane. Lines marked REQUIRED must be set before first start.
|
||||
###############################################################################
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Convex (control plane) — REQUIRED
|
||||
# Public URLs for the self-hosted Convex deployment.
|
||||
# ---------------------------------------------------------------------------
|
||||
CONVEX_URL=https://your-deployment.convex.cloud
|
||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
||||
SITE_URL=http://localhost:13100
|
||||
VITE_AUTH_URL=http://localhost:13100
|
||||
VITE_CONVEX_URL=https://your-deployment.convex.cloud
|
||||
VITE_FLUE_URL=http://localhost:3583
|
||||
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||
|
||||
# Self-hosted Convex origins used by convex/docker-compose.yml
|
||||
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
|
||||
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
|
||||
CONVEX_INSTANCE_NAME=zopu-production
|
||||
CONVEX_INSTANCE_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
|
||||
# The agent daemon clones repos and creates PRs through Gitea.
|
||||
# ---------------------------------------------------------------------------
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=replace-with-gitea-api-token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Model gateway — REQUIRED
|
||||
# All model calls route through this OpenAI-compatible endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENT_MODEL_PROVIDER=cheaptricks
|
||||
AGENT_MODEL_NAME=glm-5.2
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
|
||||
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
|
||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
|
||||
# registry.start() in the daemon boots an in-process RivetKit engine
|
||||
# (envoy mode) backed by a native Rust sidecar. createClient() connects
|
||||
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
|
||||
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
|
||||
# library default (http://localhost:6420).
|
||||
# ---------------------------------------------------------------------------
|
||||
#RIVET_ENDPOINT=http://localhost:6420
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Zopu agent service (Flue)
|
||||
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
|
||||
# zopu-agent.service pins the Flue Node server to port 3583.
|
||||
# ---------------------------------------------------------------------------
|
||||
FLUE_DB_TOKEN=replace-with-long-random-token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Daemon identity
|
||||
# ---------------------------------------------------------------------------
|
||||
DAEMON_ID=zopu-dedicated
|
||||
DAEMON_NAME=Zopu-Dedicated-Server
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Docker sandbox
|
||||
# The zopu service user is added to the docker group during bootstrap.
|
||||
# Orb sandboxes will use Docker for full-system isolation, but the Orb
|
||||
# lane contract has not landed yet. Docker access is provisioned now so
|
||||
# the boundary is ready; no Docker-backed sandbox code is wired today.
|
||||
# ---------------------------------------------------------------------------
|
||||
# No env vars required; Docker socket access is via group membership.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Service authentication / secrets
|
||||
# These tokens authenticate inter-service calls. Generate strong randoms.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
|
||||
#AUTH_SECRET=replace-with-64-char-hex
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Private networking (Tailscale) — OPTIONAL
|
||||
# When Tailscale is available, set the hostname for private DNS.
|
||||
# ---------------------------------------------------------------------------
|
||||
#TAILSCALE_HOSTNAME=zopu-runtime
|
||||
@@ -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,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
|
||||
312
docs/LOCAL_SETUP.md
Normal file
312
docs/LOCAL_SETUP.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Local Setup
|
||||
|
||||
This guide runs the active Zopu stack locally, including the browser chat and the AgentOS issue-to-PR path.
|
||||
|
||||
## What runs
|
||||
|
||||
```text
|
||||
Browser (React Router/Vite, :5173)
|
||||
-> Convex Cloud (durable product state, auth, workflows)
|
||||
-> Flue agent server (:3585)
|
||||
-> Rivet Engine guard endpoint (:6420)
|
||||
-> AgentOS registry runner
|
||||
-> isolated Pi workspace mounted from the local repository
|
||||
-> Gitea branch and pull request
|
||||
```
|
||||
|
||||
The active stack does not use `repos/`; that directory is archived reference code. In particular, the old standalone server on port `3590` is not part of this setup.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux
|
||||
- Bun and Node.js matching the repository toolchain (`packages/agents` requires Node `>=22.18 <23 || >=23.6`)
|
||||
- pnpm 11
|
||||
- Git with SSH access to `git.openputer.com`
|
||||
- [Tea](https://gitea.com/gitea/tea) authenticated to `https://git.openputer.com`
|
||||
- a Convex account with access to the development deployment
|
||||
- an OpenAI-completions-compatible model endpoint and API key
|
||||
|
||||
Install dependencies from the repository root:
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile
|
||||
```
|
||||
|
||||
Confirm Git and Tea access before testing issue or PR tools:
|
||||
|
||||
```bash
|
||||
git ls-remote origin HEAD
|
||||
tea login list
|
||||
tea issues list
|
||||
```
|
||||
|
||||
The default Tea login should target `https://git.openputer.com`. Never put credentials in committed files.
|
||||
|
||||
## Environment
|
||||
|
||||
Create the local environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
At minimum, configure these groups.
|
||||
|
||||
### Application and Convex
|
||||
|
||||
```env
|
||||
CONVEX_DEPLOYMENT=dev:<deployment-name>
|
||||
CONVEX_URL=https://<deployment>.convex.cloud
|
||||
CONVEX_SITE_URL=https://<deployment>.convex.site
|
||||
SITE_URL=http://localhost:5173
|
||||
VITE_AUTH_URL=http://localhost:5173
|
||||
VITE_CONVEX_URL=https://<deployment>.convex.cloud
|
||||
```
|
||||
|
||||
### Flue and model provider
|
||||
|
||||
```env
|
||||
FLUE_DB_TOKEN=<shared-random-token>
|
||||
FLUE_URL=http://127.0.0.1:3585
|
||||
|
||||
AGENT_MODEL_PROVIDER=<provider-name>
|
||||
AGENT_MODEL_NAME=<model-name>
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=https://<model-endpoint>/v1
|
||||
AGENT_MODEL_API_KEY=<model-api-key>
|
||||
AGENT_MODEL_CONTEXT_WINDOW=<positive-integer>
|
||||
AGENT_MODEL_MAX_TOKENS=<positive-integer>
|
||||
```
|
||||
|
||||
`FLUE_DB_TOKEN` must match the value stored in the Convex deployment.
|
||||
|
||||
### Rivet and AgentOS
|
||||
|
||||
```env
|
||||
RIVET_ENDPOINT=http://127.0.0.1:6420
|
||||
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420
|
||||
RIVET_WORKSPACE_TOKEN=<random-string-at-least-32-characters>
|
||||
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
|
||||
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
|
||||
```
|
||||
|
||||
Use the same repository checkout for `ZOPU_SOURCE_REPOSITORY`. The harness creates Git worktrees beneath `AGENT_WORKSPACE_ROOT`, copies the source checkout's `.env`, installs dependencies, and mounts the isolated checkout into AgentOS.
|
||||
|
||||
Port `6420` is the Rivet Engine guard endpoint used by the application. Port `6421` is an internal engine API-peer port and must not be used as `RIVET_ENDPOINT`.
|
||||
|
||||
### Gitea
|
||||
|
||||
```env
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=<personal-access-token>
|
||||
```
|
||||
|
||||
`GITEA_TOKEN` is optional for read-only agent startup but required for the complete issue-to-PR flow unless Tea and Git already have sufficient local credentials.
|
||||
|
||||
The legacy variables `VITE_FLUE_URL` and `VITE_ZOPU_SERVER_URL` are not used by the active web chat path.
|
||||
|
||||
## One-time Convex setup
|
||||
|
||||
On a new machine, authenticate and configure the Convex development deployment:
|
||||
|
||||
```bash
|
||||
bun run dev:setup
|
||||
```
|
||||
|
||||
Then configure the deployment-scoped values from `packages/backend`:
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
bunx convex env set SITE_URL 'http://localhost:5173'
|
||||
bunx convex env set FLUE_DB_TOKEN '<same value as .env>'
|
||||
bunx convex env set FLUE_URL 'http://<host-reachable-ip>:3585'
|
||||
```
|
||||
|
||||
Convex runs in the cloud, so `FLUE_URL` must be reachable from the Convex deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue over Tailscale and use the Mac's Tailscale address, for example `http://100.x.y.z:3585`.
|
||||
|
||||
`SITE_URL` is deployment-scoped and single-valued. Set it to the exact browser origin currently being tested:
|
||||
|
||||
```bash
|
||||
# Browser opened locally
|
||||
bunx convex env set SITE_URL 'http://localhost:5173'
|
||||
|
||||
# Browser opened from another device over Tailscale
|
||||
bunx convex env set SITE_URL 'http://<tailscale-ip>:5173'
|
||||
```
|
||||
|
||||
If work execution uses a separate agent URL, set `AGENT_BACKEND_URL`; otherwise it falls back to `FLUE_URL`.
|
||||
|
||||
## Start the stack
|
||||
|
||||
Run each process in its own terminal. The order matters for the AgentOS path.
|
||||
|
||||
### 1. Start Convex development
|
||||
|
||||
```bash
|
||||
bun run dev:server
|
||||
```
|
||||
|
||||
This watches and publishes functions to the configured Convex cloud development deployment.
|
||||
|
||||
### 2. Start Rivet Engine
|
||||
|
||||
The installed RivetKit 2.3.9 package supplies the platform-specific `rivet-engine` binary. Start it with:
|
||||
|
||||
```bash
|
||||
./node_modules/.pnpm/@rivetkit+engine-cli-*/node_modules/@rivetkit/engine-cli-*/rivet-engine start
|
||||
```
|
||||
|
||||
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
|
||||
|
||||
### 3. Start the AgentOS registry runner
|
||||
|
||||
```bash
|
||||
cd packages/agents
|
||||
bun run runner
|
||||
```
|
||||
|
||||
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
|
||||
|
||||
### 4. Start Flue agents
|
||||
|
||||
For laptop-only access:
|
||||
|
||||
```bash
|
||||
bun run dev:agents -- --port 3585
|
||||
```
|
||||
|
||||
For Convex callbacks or access from another device, bind Flue to all interfaces:
|
||||
|
||||
```bash
|
||||
bun run dev:tailscale:agents -- --port 3585
|
||||
```
|
||||
|
||||
Always pass port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
|
||||
|
||||
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
|
||||
|
||||
```bash
|
||||
RIVET_ENDPOINT=http://127.0.0.1:6420 \
|
||||
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
|
||||
bun run dev:tailscale:agents -- --port 3585
|
||||
```
|
||||
|
||||
### 5. Start the web application
|
||||
|
||||
For laptop-only access:
|
||||
|
||||
```bash
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
For phone or other Tailscale-device access:
|
||||
|
||||
```bash
|
||||
bun run dev:tailscale:web
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`, or `http://<tailscale-ip>:5173` when using the Tailscale command.
|
||||
|
||||
## Verify the setup
|
||||
|
||||
Check the listeners:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:6420
|
||||
curl -I http://127.0.0.1:3585
|
||||
curl -I http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
Any HTTP response confirms the process is reachable; these roots may redirect or return `404` because their functional routes live elsewhere.
|
||||
|
||||
Then verify behavior in order:
|
||||
|
||||
1. Open the web app and sign in.
|
||||
2. Send a simple chat message and confirm the response streams back through Convex.
|
||||
3. Ask Zopu to list open Gitea issues.
|
||||
4. For the full execution path, send `Create a PR for issue #N` for an open, unassigned test issue.
|
||||
5. Confirm chat immediately reports that the issue was accepted.
|
||||
6. Follow the Flue server logs for `[create_pr_for_issue]` and wait for a `completed` line with the PR URL.
|
||||
7. Confirm the branch and pull request in Gitea.
|
||||
|
||||
The PR pipeline is asynchronous: the initial chat turn acknowledges acceptance, while AgentOS implements, commits, pushes, and opens the pull request in the background.
|
||||
|
||||
## Request flow
|
||||
|
||||
```text
|
||||
Web sends a conversation mutation to Convex
|
||||
-> Convex persists the exact message
|
||||
-> Convex dispatches to FLUE_URL/agents/zopu/<organizationId>
|
||||
-> Flue runs the Zopu agent and its typed tools
|
||||
-> responses return to Convex
|
||||
-> the web observes Convex's reactive projection
|
||||
|
||||
For code execution:
|
||||
-> Flue calls the local AgentOS harness
|
||||
-> the harness creates an isolated Git worktree
|
||||
-> a Rivet actor boots an AgentOS VM and mounts the worktree
|
||||
-> Pi implements the issue and produces a candidate revision
|
||||
-> the host pushes a unique branch
|
||||
-> Tea creates a Gitea pull request
|
||||
```
|
||||
|
||||
## Common failures
|
||||
|
||||
### Chat stays pending or reports that Flue is unavailable
|
||||
|
||||
- Confirm Flue is listening on `3585`.
|
||||
- Confirm the Convex deployment's `FLUE_URL` uses a host-reachable address, not `localhost`.
|
||||
- Confirm `FLUE_DB_TOKEN` is identical in `.env` and the Convex deployment.
|
||||
|
||||
### Sign-in fails or the browser reports CORS errors
|
||||
|
||||
Set Convex `SITE_URL` to the exact browser origin. The shared development deployment trusts only the current single value.
|
||||
|
||||
### AgentOS fails while mounting the repository
|
||||
|
||||
- Confirm both Rivet endpoints use port `6420`.
|
||||
- Confirm the engine and `bun run runner` are both running.
|
||||
- Confirm `ZOPU_SOURCE_REPOSITORY` contains `.git`.
|
||||
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
|
||||
- Restart the runner after changing AgentOS registry configuration; Flue hot reload is not enough.
|
||||
|
||||
The harness clients require CBOR encoding for host-directory mount descriptors. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/runtime/agent-os.ts`.
|
||||
|
||||
### AgentOS reports an ACP completed-message resource limit
|
||||
|
||||
The workspace registry raises `limits.acp.maxCompletedMessageBytes` for long Pi coding runs. Restart the runner so the updated registry configuration is registered with Rivet Engine.
|
||||
|
||||
### Flue connects to a remote Rivet deployment unexpectedly
|
||||
|
||||
Environment variables exported by the shell override values loaded from `.env`. Start Flue and the runner with explicit `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` values pointing to `127.0.0.1:6420`.
|
||||
|
||||
### Gitea issue or PR commands fail
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tea login list
|
||||
git ls-remote origin HEAD
|
||||
tea issues list
|
||||
```
|
||||
|
||||
Confirm the default Tea login, SSH key, repository remote, and token all target `git.openputer.com`.
|
||||
|
||||
## Repository checks
|
||||
|
||||
After changing source or configuration:
|
||||
|
||||
```bash
|
||||
bunx ultracite check <changed-files>
|
||||
bun run check-types
|
||||
bun run check
|
||||
```
|
||||
|
||||
For agent-only changes, run `bun run check-types` from `packages/agents` before the root check.
|
||||
|
||||
See also:
|
||||
|
||||
- [`TECH.md`](TECH.md) for architecture and ownership boundaries
|
||||
- [`deployment.md`](deployment.md) for the shared staging deployment
|
||||
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
|
||||
- [Flue local development](https://flue.dev/docs/cli/dev)
|
||||
@@ -16,10 +16,12 @@ Dense context set for product development and coding agents.
|
||||
9. evaluation.md — quality measurement and improvement
|
||||
```
|
||||
|
||||
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
|
||||
|
||||
## Use by role
|
||||
|
||||
| Role | Minimum context |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Product/definition agent | agent-context, product, glossary, dev-loop |
|
||||
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
|
||||
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |
|
||||
|
||||
221
docs/TECH.md
221
docs/TECH.md
@@ -18,29 +18,28 @@ Convex application backend
|
||||
├── normalized product data
|
||||
├── conversation turn queue
|
||||
└── reactive client projections
|
||||
│ service-authenticated dispatch
|
||||
│ durable Workflow steps + service-authenticated dispatch
|
||||
▼
|
||||
FLUE orchestration service
|
||||
├── model calls
|
||||
├── typed tools
|
||||
└── canonical Flue persistence in Convex
|
||||
│ later execution commands
|
||||
Private agent backend
|
||||
├── FLUE product agents and typed tools
|
||||
├── AgentOS execution environments
|
||||
├── Codex implementation harness
|
||||
└── canonical events/results returned to Convex
|
||||
│ optional attached full sandbox
|
||||
▼
|
||||
Rivet Engine + AgentOS (post-Slice 1)
|
||||
└── sandboxes, harnesses, and durable execution
|
||||
Cube/E2B-compatible runtime (later)
|
||||
```
|
||||
|
||||
Clients MUST communicate only with Convex. FLUE and future Rivet/AgentOS
|
||||
services are private workers: Convex admits durable commands, invokes the
|
||||
worker, and stores the product-facing result before clients observe it.
|
||||
Clients MUST communicate only with Convex. FLUE and future Rivet/AgentOS services are private workers: Convex admits durable commands, invokes the worker, and stores the product-facing result before clients observe it.
|
||||
|
||||
### Ownership
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Convex | authentication, normalized product records, command admission, reactive reads |
|
||||
| FLUE | private programmable orchestration and domain-specific agents |
|
||||
| Rivet actors (later) | serialized execution ownership, recovery, timers, leases |
|
||||
| Convex Workflow | durable sequencing, retries, cancellation, scheduling, and completion callbacks |
|
||||
| Agent backend | private programmable agents and execution adapters |
|
||||
| Rivet Engine + runners | placement, routing, sleep/wake, and execution ownership for AgentOS workspaces |
|
||||
| Harness | bounded coding/tool loop |
|
||||
| Sandbox/runtime | filesystem, processes, network, isolation |
|
||||
| Git | source revision history |
|
||||
@@ -63,11 +62,7 @@ signals ──< signalWorkAttachments >── works
|
||||
works ──< workEvents
|
||||
```
|
||||
|
||||
Convex mutations provide atomic transactions and optimistic serializability.
|
||||
Foreign-key integrity and uniqueness are enforced in the owning mutations;
|
||||
composite indexes back every identity and relation lookup. Flue's adapter
|
||||
tables remain isolated infrastructure persistence and are not product-domain
|
||||
relations.
|
||||
Convex mutations provide atomic transactions and optimistic serializability. Foreign-key integrity and uniqueness are enforced in the owning mutations; composite indexes back every identity and relation lookup. Flue's adapter tables remain isolated infrastructure persistence and are not product-domain relations.
|
||||
|
||||
## 2. Domain boundaries
|
||||
|
||||
@@ -106,93 +101,93 @@ Avoid package proliferation in v0; logical boundaries may begin as folders.
|
||||
Minimal durable model:
|
||||
|
||||
```ts
|
||||
type Id = string
|
||||
type Id = string;
|
||||
|
||||
interface Message {
|
||||
id: Id
|
||||
projectId: Id
|
||||
content: string
|
||||
createdAt: number
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface Signal {
|
||||
id: Id
|
||||
projectId: Id
|
||||
sourceType: string
|
||||
sourceId: string
|
||||
sourcePayloadRef?: string
|
||||
summary: string
|
||||
fingerprint: string
|
||||
status: "candidate" | "accepted" | "dismissed"
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
sourcePayloadRef?: string;
|
||||
summary: string;
|
||||
fingerprint: string;
|
||||
status: "candidate" | "accepted" | "dismissed";
|
||||
}
|
||||
|
||||
interface Work {
|
||||
id: Id
|
||||
projectId: Id
|
||||
title: string
|
||||
objective: string
|
||||
risk: "low" | "medium" | "high"
|
||||
status: WorkStatus
|
||||
definitionVersion?: number
|
||||
designVersion?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
id: Id;
|
||||
projectId: Id;
|
||||
title: string;
|
||||
objective: string;
|
||||
risk: "low" | "medium" | "high";
|
||||
status: WorkStatus;
|
||||
definitionVersion?: number;
|
||||
designVersion?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: Id
|
||||
workId: Id
|
||||
sliceId?: Id
|
||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe"
|
||||
objective: string
|
||||
dependsOn: readonly Id[]
|
||||
status: StepStatus
|
||||
id: Id;
|
||||
workId: Id;
|
||||
sliceId?: Id;
|
||||
kind: "design" | "implement" | "verify" | "integrate" | "publish" | "observe";
|
||||
objective: string;
|
||||
dependsOn: readonly Id[];
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId: Id
|
||||
kitVersion: string
|
||||
status: RunStatus
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId: Id;
|
||||
kitVersion: string;
|
||||
status: RunStatus;
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
id: Id
|
||||
runId: Id
|
||||
number: number
|
||||
harness: string
|
||||
runtime: string
|
||||
sourceRevision: string
|
||||
status: AttemptStatus
|
||||
startedAt?: number
|
||||
endedAt?: number
|
||||
id: Id;
|
||||
runId: Id;
|
||||
number: number;
|
||||
harness: string;
|
||||
runtime: string;
|
||||
sourceRevision: string;
|
||||
status: AttemptStatus;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
interface Artifact {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
runId?: Id
|
||||
attemptId?: Id
|
||||
type: string
|
||||
uri?: string
|
||||
contentHash?: string
|
||||
sourceRevision?: string
|
||||
environmentId?: string
|
||||
metadata: unknown
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId?: Id;
|
||||
runId?: Id;
|
||||
attemptId?: Id;
|
||||
type: string;
|
||||
uri?: string;
|
||||
contentHash?: string;
|
||||
sourceRevision?: string;
|
||||
environmentId?: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: Id
|
||||
workId: Id
|
||||
stepId?: Id
|
||||
attemptId?: Id
|
||||
prompt: string
|
||||
recommendation?: string
|
||||
alternatives: readonly string[]
|
||||
status: "open" | "answered" | "withdrawn"
|
||||
answer?: string
|
||||
id: Id;
|
||||
workId: Id;
|
||||
stepId?: Id;
|
||||
attemptId?: Id;
|
||||
prompt: string;
|
||||
recommendation?: string;
|
||||
alternatives: readonly string[];
|
||||
status: "open" | "answered" | "withdrawn";
|
||||
answer?: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -337,35 +332,49 @@ Keep domain/application code provider-neutral.
|
||||
|
||||
```ts
|
||||
interface HarnessRuntime {
|
||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>
|
||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>
|
||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>
|
||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>
|
||||
abort(id: string): Effect.Effect<void, HarnessError>
|
||||
close(id: string): Effect.Effect<void, HarnessError>
|
||||
open(input: OpenHarnessInput): Effect.Effect<HarnessSession, HarnessError>;
|
||||
prompt(id: string, content: string): Effect.Effect<void, HarnessError>;
|
||||
events(id: string): Stream.Stream<HarnessEvent, HarnessError>;
|
||||
approve(input: PermissionDecision): Effect.Effect<void, HarnessError>;
|
||||
abort(id: string): Effect.Effect<void, HarnessError>;
|
||||
close(id: string): Effect.Effect<void, HarnessError>;
|
||||
}
|
||||
|
||||
interface SandboxRuntime {
|
||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>
|
||||
exec(lease: SandboxLease, cmd: Command): Effect.Effect<CommandResult, SandboxError>
|
||||
readFile(lease: SandboxLease, path: string): Effect.Effect<Uint8Array, SandboxError>
|
||||
writeFile(lease: SandboxLease, path: string, body: Uint8Array): Effect.Effect<void, SandboxError>
|
||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>
|
||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>
|
||||
create(spec: SandboxSpec): Effect.Effect<SandboxLease, SandboxError>;
|
||||
exec(
|
||||
lease: SandboxLease,
|
||||
cmd: Command
|
||||
): Effect.Effect<CommandResult, SandboxError>;
|
||||
readFile(
|
||||
lease: SandboxLease,
|
||||
path: string
|
||||
): Effect.Effect<Uint8Array, SandboxError>;
|
||||
writeFile(
|
||||
lease: SandboxLease,
|
||||
path: string,
|
||||
body: Uint8Array
|
||||
): Effect.Effect<void, SandboxError>;
|
||||
pause(lease: SandboxLease): Effect.Effect<void, SandboxError>;
|
||||
resume(id: string): Effect.Effect<SandboxLease, SandboxError>;
|
||||
terminate(lease: SandboxLease): Effect.Effect<void, SandboxError>;
|
||||
}
|
||||
|
||||
interface SourceControl {
|
||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>
|
||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>
|
||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>
|
||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>
|
||||
createPullRequest(input: PullRequestInput): Effect.Effect<PullRequestArtifact, GitError>
|
||||
prepareWorktree(input: WorktreeInput): Effect.Effect<Worktree, GitError>;
|
||||
diff(worktree: Worktree): Effect.Effect<DiffArtifact, GitError>;
|
||||
commit(input: CommitInput): Effect.Effect<CommitArtifact, GitError>;
|
||||
push(input: PushInput): Effect.Effect<BranchArtifact, GitError>;
|
||||
createPullRequest(
|
||||
input: PullRequestInput
|
||||
): Effect.Effect<PullRequestArtifact, GitError>;
|
||||
}
|
||||
|
||||
interface VerificationRuntime {
|
||||
execute(plan: VerificationPlan, env: EnvironmentRef):
|
||||
Effect.Effect<VerificationResult, VerificationError>
|
||||
execute(
|
||||
plan: VerificationPlan,
|
||||
env: EnvironmentRef
|
||||
): Effect.Effect<VerificationResult, VerificationError>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -420,6 +429,10 @@ Zopu owns Work, branches, worktrees, budgets, approvals, artifacts, and completi
|
||||
|
||||
## 10. Runtime strategy
|
||||
|
||||
### Convex orchestration
|
||||
|
||||
Slice 5 uses the Convex Workflow component as the durable product orchestration layer. Workflow steps call private agent-backend actions, while all user-visible state, events, artifacts, idempotency keys, and cancellation remain canonical in Convex.
|
||||
|
||||
### CubeSandbox
|
||||
|
||||
Best for full Linux execution:
|
||||
@@ -443,7 +456,7 @@ Best for:
|
||||
- actor-adjacent orchestration;
|
||||
- context/files/networking that fit runtime limits.
|
||||
|
||||
Use an attached full sandbox when native/heavy tooling is needed.
|
||||
Slice 5 starts here with one Codex-backed AgentOS actor and one authenticated repository checkout per project. Convex Workflow owns product orchestration; Rivet Engine coordinates placement while a normal runner executes the actor. Use an attached full sandbox through the E2B-compatible boundary when native/heavy tooling is needed.
|
||||
|
||||
### Persistent project machine
|
||||
|
||||
|
||||
78
docs/auth-proxy.md
Normal file
78
docs/auth-proxy.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Auth Proxy — Production Ingress Requirement
|
||||
|
||||
The application uses **same-origin authentication**: the browser and React Router SSR both hit
|
||||
`/api/auth/*` on the public application domain. This keeps cookies first-party, avoids
|
||||
cross-origin credentials, and gives development and production the same API surface.
|
||||
|
||||
## Required production route
|
||||
|
||||
At the public application domain, route:
|
||||
|
||||
| Prefix | Target |
|
||||
|---|---|
|
||||
| `/api/auth/*` | Convex HTTP site |
|
||||
| `/*` | React Router frontend |
|
||||
|
||||
### Caddy
|
||||
|
||||
```caddy
|
||||
zopu.example.com {
|
||||
handle /api/auth/* {
|
||||
reverse_proxy https://befitting-dalmatian-161.convex.site {
|
||||
header_up Host befitting-dalmatian-161.convex.site
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dokploy / Traefik
|
||||
|
||||
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
|
||||
site URL. Ensure it:
|
||||
|
||||
- Preserves the original browser `Cookie` header
|
||||
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
|
||||
- Preserves the original method and body
|
||||
- Forwards `X-Forwarded-Host` and `X-Forwarded-Proto`
|
||||
- Passes the full path unchanged (e.g. `/api/auth/convex/token`)
|
||||
- Does not cache auth responses
|
||||
- Allows OAuth callback routes under the same prefix
|
||||
|
||||
## Convex environment
|
||||
|
||||
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
|
||||
site URL:
|
||||
|
||||
```bash
|
||||
# Production
|
||||
npx convex env set SITE_URL 'https://zopu.example.com'
|
||||
|
||||
# Local
|
||||
npx convex env set SITE_URL 'http://100.101.157.28:5173'
|
||||
|
||||
# Staging
|
||||
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
|
||||
```
|
||||
|
||||
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
|
||||
`CONVEX_SITE_URL` as the internal `baseURL` for route registration; that value is the HTTP
|
||||
site URL (e.g. `https://befitting-dalmatian-161.convex.site`).
|
||||
|
||||
## Why same-origin
|
||||
|
||||
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
|
||||
2. **SSR consistency.** The React Router server uses the same auth surface the browser
|
||||
sees; no cross-host credential translation.
|
||||
3. **No CORS complexity.** The browser talks only to its own origin.
|
||||
4. **The reverse proxy handles the cross-host hop server-side.**
|
||||
|
||||
## Related
|
||||
|
||||
- [Better Auth: Trusted Origins](https://github.com/better-auth/better-auth/blob/main/docs/content/docs/reference/security.mdx)
|
||||
- `apps/web/vite.config.ts` — Vite dev proxy (`/api/auth` → Convex site)
|
||||
- `packages/auth/src/web/auth-client.ts` — `window.location.origin`
|
||||
- `apps/web/src/lib/auth.server.ts` — SSR token loader via request origin
|
||||
@@ -211,16 +211,18 @@ Zopu implements one approved slice in an isolated repository environment and str
|
||||
Initial adapter choice:
|
||||
|
||||
```text
|
||||
SandboxRuntime = CubeSandboxLive
|
||||
HarnessRuntime = OmpHarnessLive (or one chosen harness)
|
||||
SandboxRuntime = AgentOsSandboxLive
|
||||
HarnessRuntime = CodexHarnessLive
|
||||
Durable orchestration = Convex Workflow
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
prepare worktree
|
||||
→ create sandbox
|
||||
→ clone/mount repo
|
||||
load the project's authenticated Git connection
|
||||
→ start durable Convex workflow
|
||||
→ create AgentOS execution environment
|
||||
→ clone the single configured repo
|
||||
→ inject context
|
||||
→ run one slice
|
||||
→ normalize events
|
||||
@@ -230,14 +232,15 @@ prepare worktree
|
||||
|
||||
Security:
|
||||
|
||||
- scoped Git/model tokens;
|
||||
- GitHub OAuth or self-hosted Gitea PAT, scoped to one project;
|
||||
- scoped Git/model tokens passed only to private execution;
|
||||
- isolated HOME/worktree;
|
||||
- one mutating attempt per worktree;
|
||||
- timeout/cancel cleanup.
|
||||
|
||||
### Frontend
|
||||
|
||||
Current activity, changed files, artifact links, expandable raw logs.
|
||||
Project selector/settings, Git connection status, current activity, changed files, artifact links, expandable raw logs, cancel/retry, and manual Git delivery controls.
|
||||
|
||||
### Acceptance
|
||||
|
||||
@@ -247,6 +250,8 @@ Current activity, changed files, artifact links, expandable raw logs.
|
||||
- provider failure becomes classified attempt outcome;
|
||||
- exact base/candidate revision recorded.
|
||||
|
||||
Rivet Engine coordinates AgentOS actor placement through a normal runner, but does not own product orchestration; Convex Workflow remains canonical for the Run lifecycle. Cube/Kubernetes sandbox support remains behind `SandboxRuntime` and can be mounted through AgentOS incrementally.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Independent verification and repair
|
||||
@@ -490,4 +495,4 @@ browser-tester integration-coordinator
|
||||
1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12
|
||||
```
|
||||
|
||||
Parallel engineering is allowed *within* a slice after contracts land, but product release order remains sequential.
|
||||
Parallel engineering is allowed _within_ a slice after contracts land, but product release order remains sequential.
|
||||
|
||||
@@ -5,5 +5,28 @@ import remix from "ultracite/oxlint/remix";
|
||||
|
||||
export default defineConfig({
|
||||
extends: [core, react, remix],
|
||||
ignorePatterns: core.ignorePatterns,
|
||||
ignorePatterns: [...core.ignorePatterns, "repos/**", "scripts/**"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/convex/**/*.ts", "**/convex/**/*.test.ts"],
|
||||
rules: {
|
||||
"unicorn/filename-case": "off",
|
||||
// Convex targets ES2022: no toSorted/at; sequential awaits ensure atomicity
|
||||
"unicorn/no-array-sort": "off",
|
||||
"no-await-in-loop": "off",
|
||||
// Convex query builders use `any` in index callbacks
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"eslint/complexity": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/convex/**/*.test.ts"],
|
||||
rules: {
|
||||
"unicorn/no-await-expression-member": "off",
|
||||
"eslint/require-await": "off",
|
||||
"sort-keys": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
64
package.json
64
package.json
@@ -1,52 +1,13 @@
|
||||
{
|
||||
"name": "code",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"apps/web",
|
||||
"packages/agents",
|
||||
"packages/auth",
|
||||
"packages/backend",
|
||||
"packages/config",
|
||||
"packages/env",
|
||||
"packages/primitives",
|
||||
"packages/ui"
|
||||
],
|
||||
"catalog": {
|
||||
"@rivet-dev/agentos": "^0.2.7",
|
||||
"@rivet-dev/agentos-core": "^0.2.10",
|
||||
"@effect/platform-bun": "4.0.0-beta.99",
|
||||
"dotenv": "^17.4.2",
|
||||
"zod": "^4.4.3",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"sonner": "^2.0.7",
|
||||
"convex": "^1.42.1",
|
||||
"better-auth": "1.6.15",
|
||||
"@convex-dev/better-auth": "^0.12.5",
|
||||
"@tanstack/react-form": "^1.33.0",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"@better-auth/expo": "1.6.15",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^6",
|
||||
"@types/bun": "latest",
|
||||
"heroui-native": "^1.0.5",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.10",
|
||||
"convex-test": "^0.0.54"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vp run -r dev",
|
||||
"build": "vp run -r build",
|
||||
"check-types": "vp run -r check-types",
|
||||
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/slice-one apps/web/src/hooks/chat apps/web/src/hooks/slice-one apps/web/src/lib/chat apps/web/src/lib/slice-one apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/projects.ts packages/backend/convex/schema.ts packages/backend/convex/signalRouting.ts packages/backend/convex/signalRouting.test.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/backend/convex/workExecution.ts packages/backend/convex/workExecution.test.ts packages/backend/convex/workPlanning.ts packages/backend/convex/workPlanning.test.ts packages/backend/convex/crons.ts packages/primitives/src/work.ts packages/primitives/src/work.test.ts packages/primitives/src/resolver.ts packages/primitives/src/work-lifecycle.ts packages/primitives/src/work-resolution.test.ts",
|
||||
"lint": "vp lint",
|
||||
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
|
||||
"lint": "oxlint --disable-nested-config",
|
||||
"format": "vp fmt",
|
||||
"staged": "vp staged",
|
||||
"hooks:setup": "vp config",
|
||||
@@ -57,14 +18,10 @@
|
||||
"dev:server": "vp run --filter @code/backend dev",
|
||||
"dev:setup": "vp run --filter @code/backend dev:setup",
|
||||
"build:agents": "vp run --filter @code/agents build",
|
||||
"docs:update": "bun run scripts/update-docs.ts",
|
||||
"subtree": "bun run scripts/subtree.ts",
|
||||
"fix": "ultracite fix",
|
||||
"slice1": "vp run -r dev",
|
||||
"dev:zopu": "vp run --filter @code/agents dev",
|
||||
"dev:zopu:web": "vp run --filter web dev"
|
||||
"docs:update": "node scripts/update-docs.ts",
|
||||
"subtree": "node scripts/subtree.ts",
|
||||
"fix": "ultracite fix"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/config": "workspace:*",
|
||||
@@ -72,20 +29,17 @@
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/node": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"convex-test": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"oxfmt": "latest",
|
||||
"oxlint": "latest",
|
||||
"oxfmt": "0.61.0",
|
||||
"oxlint": "1.76.0",
|
||||
"rolldown": "1.1.4",
|
||||
"typescript": "catalog:",
|
||||
"ultracite": "7.9.3",
|
||||
"vite-plus": "0.2.2",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
|
||||
},
|
||||
"packageManager": "bun@1.3.14"
|
||||
"packageManager": "pnpm@11.17.0"
|
||||
}
|
||||
|
||||
32
packages/agents/Dockerfile
Normal file
32
packages/agents/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM oven/bun:1.3.14 AS bun
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN node packages/agents/node_modules/@flue/cli/bin/flue.mjs build --target node --root packages/agents
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ENV NODE_OPTIONS=--experimental-specifier-resolution=node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app /app
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "packages/agents/dist/server.mjs"]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from '@flue/cli/config';
|
||||
import { defineConfig } from "@flue/cli/config";
|
||||
|
||||
export default defineConfig({
|
||||
target: 'node',
|
||||
target: "node",
|
||||
});
|
||||
|
||||
@@ -4,26 +4,37 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --env-file=../../.env flue build",
|
||||
"build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
|
||||
"start": "node --env-file=../../.env dist/server.mjs",
|
||||
"check-types": "tsc --noEmit",
|
||||
"dev": "bun --env-file=../../.env flue dev",
|
||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
||||
"run": "bun --env-file=../../.env flue run",
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu",
|
||||
"run:work-planner": "bun --env-file=../../.env flue run work-planner"
|
||||
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
|
||||
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
|
||||
"runner": "bun --env-file=../../.env src/runner.ts",
|
||||
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
|
||||
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"@rivet-dev/agentos-core": "0.2.14",
|
||||
"convex": "catalog:",
|
||||
"hono": "4.12.31",
|
||||
"valibot": "^1.4.2"
|
||||
"hono": "catalog:",
|
||||
"rivetkit": "2.3.9",
|
||||
"valibot": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@flue/cli": "latest",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18 <23 || >=23.6"
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/agents/src/admission-context.ts
Normal file
16
packages/agents/src/admission-context.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export interface TurnAdmissionContext {
|
||||
readonly clientRequestId: string;
|
||||
readonly turnId: string;
|
||||
}
|
||||
|
||||
const turnAdmissionContext = new AsyncLocalStorage<TurnAdmissionContext>();
|
||||
|
||||
export const currentTurnAdmission = (): TurnAdmissionContext | undefined =>
|
||||
turnAdmissionContext.getStore();
|
||||
|
||||
export const withTurnAdmission = <T>(
|
||||
context: TurnAdmissionContext,
|
||||
run: () => T
|
||||
): T => turnAdmissionContext.run(context, run);
|
||||
@@ -1,59 +1,21 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
|
||||
import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
|
||||
import { createSliceOneTools } from "../tools/slice-one";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu for product Slice 1 and the Work planning handoff.
|
||||
|
||||
## Your role
|
||||
|
||||
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
|
||||
|
||||
## Work routing loop
|
||||
|
||||
When a user sends a message, follow this decision flow:
|
||||
|
||||
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
|
||||
|
||||
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
|
||||
|
||||
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
|
||||
|
||||
3. **Create a Signal (only when actionable).** When the message is actionable:
|
||||
a. Call list_signal_evidence to see the exact admitted user messages.
|
||||
b. Select the message IDs that compose the problem statement.
|
||||
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
|
||||
d. Include the projectId when the project is known.
|
||||
|
||||
4. **Route the Signal.** After creating the Signal:
|
||||
a. Call list_proposed_work for the project.
|
||||
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
|
||||
c. Otherwise call create_work_from_signal.
|
||||
e. If genuinely uncertain whether to attach or create, ask one focused question.
|
||||
|
||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||
- "Captured [Signal title] and linked it to [Work title]."
|
||||
- "Captured [Signal title] and proposed [Work title]."
|
||||
- Keep the response brief; the product renders the durable Work card separately.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
|
||||
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
|
||||
- Never create Work from casual chat.
|
||||
- Ask at most one focused clarification when genuinely ambiguous.
|
||||
- Preserve project and organization scope at all times.
|
||||
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
|
||||
- Never claim you created a Signal until the tool call returns successfully.
|
||||
- Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
|
||||
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
|
||||
- Proposed Work is the only Work status you directly create.`;
|
||||
|
||||
export {
|
||||
convexAgentRoute as attachments,
|
||||
convexAgentRoute as route,
|
||||
} from "../auth";
|
||||
|
||||
// The host checkout the chat agent explores. The `local()` sandbox reuses the
|
||||
// machine's existing shell, Git, and Tea install unchanged; its cwd becomes the
|
||||
// default working directory for every command the agent runs.
|
||||
const ZOPU_CODE_PATH = "/Users/puter/Workspace/zopu/code";
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
@@ -62,7 +24,8 @@ export default defineAgent(({ env, id }) => {
|
||||
"Turns actionable conversation into provenanced Signals and proposed Work.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
thinkingLevel: "medium",
|
||||
tools: createSliceOneTools(id, env),
|
||||
sandbox: local({ cwd: ZOPU_CODE_PATH }),
|
||||
thinkingLevel: "high",
|
||||
tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
|
||||
import { registerProvider } from "@flue/runtime";
|
||||
import type { Fetchable } from "@flue/runtime/routing";
|
||||
import { flue } from "@flue/runtime/routing";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
cancelAgentOsAttempt,
|
||||
executeAgentOsAttempt,
|
||||
runtimeRegistry,
|
||||
} from "./runtime/agent-os";
|
||||
|
||||
const agentEnv = parseAgentEnv(process.env);
|
||||
|
||||
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
@@ -25,6 +33,54 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/", flue() as unknown as Hono);
|
||||
|
||||
export default app;
|
||||
app.post("/internal/work-attempts/execute", async (context) => {
|
||||
if (
|
||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
||||
) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
try {
|
||||
return context.json(await executeAgentOsAttempt(await context.req.json()));
|
||||
} catch (error) {
|
||||
const failure =
|
||||
error instanceof WorkAttemptExecutionError
|
||||
? error
|
||||
: new WorkAttemptExecutionError({
|
||||
message:
|
||||
error instanceof Error ? error.message : "Execution failed",
|
||||
reason: "HarnessFailed",
|
||||
retryable: false,
|
||||
});
|
||||
return context.json(
|
||||
{
|
||||
error: {
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
|
||||
if (
|
||||
context.req.header("authorization") !== `Bearer ${agentEnv.FLUE_DB_TOKEN}`
|
||||
) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const body = (await context.req.json()) as { attemptId?: unknown };
|
||||
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
|
||||
return context.json({ error: "attemptId is required" }, 400);
|
||||
}
|
||||
await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
|
||||
return context.json({ cancelled: true });
|
||||
});
|
||||
|
||||
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
|
||||
|
||||
app.route("/", flue());
|
||||
|
||||
export default app satisfies Fetchable;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { withTurnAdmission } from "./admission-context";
|
||||
|
||||
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
|
||||
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
|
||||
const env = parseAgentEnv(process.env);
|
||||
@@ -14,5 +16,15 @@ export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
|
||||
return context.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
return await next().then(() => context.res);
|
||||
if (context.req.method !== "POST") {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const clientRequestId = context.req.header("x-zopu-request-id");
|
||||
const turnId = context.req.header("x-zopu-turn-id");
|
||||
if (!clientRequestId || !turnId) {
|
||||
return context.json({ error: "Missing turn correlation headers" }, 400);
|
||||
}
|
||||
|
||||
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
57
packages/agents/src/prompts/zopu-instructions.md
Normal file
57
packages/agents/src/prompts/zopu-instructions.md
Normal file
@@ -0,0 +1,57 @@
|
||||
You are Zopu for product Slice 1 and the Work planning handoff.
|
||||
|
||||
## Your role
|
||||
|
||||
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
|
||||
|
||||
## Work routing loop
|
||||
|
||||
When a user sends a message, follow this decision flow:
|
||||
|
||||
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
|
||||
|
||||
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
|
||||
|
||||
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
|
||||
|
||||
3. **Create a Signal (only when actionable).** When the message is actionable: a. Call list_signal_evidence to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the projectId when the project is known.
|
||||
|
||||
4. **Route the Signal.** After creating the Signal: a. Call list_proposed_work for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work. c. Otherwise call create_work_from_signal. e. If genuinely uncertain whether to attach or create, ask one focused question.
|
||||
|
||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||
- "Captured [Signal title] and linked it to [Work title]."
|
||||
- "Captured [Signal title] and proposed [Work title]."
|
||||
- Keep the response brief; the product renders the durable Work card separately.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
|
||||
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
|
||||
- Never create Work from casual chat.
|
||||
- Ask at most one focused clarification when genuinely ambiguous.
|
||||
- Preserve project and organization scope at all times.
|
||||
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
|
||||
- Never claim you created a Signal until the tool call returns successfully.
|
||||
- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
|
||||
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
|
||||
- Proposed Work is the only Work status you directly create.
|
||||
|
||||
## Issue resolution with create_pr_for_issue
|
||||
|
||||
When a user wants an open Gitea issue implemented and turned into a pull request, call `create_pr_for_issue` with the issue number or URL.
|
||||
|
||||
- The tool accepts the request immediately and runs the AgentOS implementation, branch push, and PR creation in the background.
|
||||
- Tell the user the issue was accepted for background implementation; do not claim the PR exists yet and do not wait for completion in the chat turn.
|
||||
- Do not retry the tool in a tight loop. Background success and failure are written to the agent server logs with the `[create_pr_for_issue]` prefix.
|
||||
- Do not run repository mutations yourself; the tool owns the implementation and PR pipeline.
|
||||
|
||||
## Repository access
|
||||
|
||||
Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace/zopu/code`, so you can answer questions about this repository and manage its Gitea issues.
|
||||
|
||||
- Explore the repository with read-only Git: `git log`, `git status`, `git diff`, `git branch`, `git show`, and reading files. Use this to ground answers about the codebase.
|
||||
- List open issues with `tea issues list` (defaults to open issues).
|
||||
- Create a Gitea issue in this repo with `tea issues create --title "<title>" [--description "<details>"]`.
|
||||
- Never mutate repository state: do not run `git push`, `git merge`, `git rebase`, `git reset`, `git commit`, `git add`, force operations, or any history rewrite.
|
||||
- Never close, reopen, or edit existing issues (`tea issues close|reopen|edit`); only list and create.
|
||||
- Never expose credentials, tokens, or the contents of Tea/Git config files.
|
||||
3
packages/agents/src/runner.ts
Normal file
3
packages/agents/src/runner.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { runtimeRegistry } from "./runtime/agent-os";
|
||||
|
||||
await runtimeRegistry.startAndWait();
|
||||
265
packages/agents/src/runtime/agent-os.ts
Normal file
265
packages/agents/src/runtime/agent-os.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import {
|
||||
makePiAgentOsConfig,
|
||||
makePiHomeFiles,
|
||||
decodeWorkAttemptExecutionInput,
|
||||
WorkAttemptExecutionError,
|
||||
piSessionEnv,
|
||||
} from "@code/primitives";
|
||||
import type {
|
||||
ExecutionEvent,
|
||||
WorkAttemptExecutionResult,
|
||||
} from "@code/primitives";
|
||||
import { agentOS, setup } from "@rivet-dev/agentos";
|
||||
import { createHostDirBackend } from "@rivet-dev/agentos-core";
|
||||
import { createClient } from "@rivet-dev/agentos/client";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { HostRepositoryWorkspace } from "./host-repository";
|
||||
|
||||
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
|
||||
const piConfig = makePiAgentOsConfig();
|
||||
const workspace = agentOS<undefined, { token: string }>({
|
||||
limits: {
|
||||
acp: {
|
||||
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
|
||||
},
|
||||
},
|
||||
onBeforeConnect: (_context, params) => {
|
||||
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
|
||||
throw new Error("Unauthorized workspace connection");
|
||||
}
|
||||
},
|
||||
options: {
|
||||
actionTimeout: 10 * 60 * 1000,
|
||||
},
|
||||
permissions: piConfig.permissions,
|
||||
software: piConfig.software,
|
||||
});
|
||||
|
||||
export const runtimeRegistry = setup({ use: { workspace } });
|
||||
|
||||
const event = (
|
||||
sequence: number,
|
||||
kind: ExecutionEvent["kind"],
|
||||
message: string,
|
||||
metadata: Record<string, string> = {}
|
||||
): ExecutionEvent => ({
|
||||
kind,
|
||||
message,
|
||||
metadata,
|
||||
occurredAt: Date.now(),
|
||||
sequence,
|
||||
});
|
||||
|
||||
const executionError = (
|
||||
message: string,
|
||||
reason: WorkAttemptExecutionError["reason"],
|
||||
retryable: boolean
|
||||
) => new WorkAttemptExecutionError({ message, reason, retryable });
|
||||
|
||||
const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
|
||||
if (cause instanceof WorkAttemptExecutionError) {
|
||||
return cause;
|
||||
}
|
||||
const message = cause instanceof Error ? cause.message : "Execution failed";
|
||||
if (/auth|credential|401|403/iu.test(message)) {
|
||||
return executionError(message, "Authentication", false);
|
||||
}
|
||||
if (/clone|checkout|repository|git /iu.test(message)) {
|
||||
return executionError(message, "RepositoryFailed", false);
|
||||
}
|
||||
if (/timeout|timed out/iu.test(message)) {
|
||||
return executionError(message, "Timeout", true);
|
||||
}
|
||||
if (/connect|network|unavailable|502|503|504/iu.test(message)) {
|
||||
return executionError(message, "ProviderUnavailable", true);
|
||||
}
|
||||
return executionError(message, "HarnessFailed", false);
|
||||
};
|
||||
|
||||
export const executeAgentOsAttempt = async (
|
||||
rawInput: unknown
|
||||
): Promise<WorkAttemptExecutionResult> => {
|
||||
try {
|
||||
const input = await Effect.runPromise(
|
||||
decodeWorkAttemptExecutionInput(rawInput)
|
||||
);
|
||||
const env = parseAgentEnv(process.env);
|
||||
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
|
||||
const client = createClient<typeof runtimeRegistry>({
|
||||
disableMetadataLookup: true,
|
||||
encoding: "cbor",
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
});
|
||||
const vm = client.workspace.getOrCreate([input.workspaceKey], {
|
||||
params: { token: env.RIVET_WORKSPACE_TOKEN },
|
||||
});
|
||||
const events: ExecutionEvent[] = [
|
||||
event(0, "runtime.preparing", "AgentOS workspace selected", {
|
||||
workspaceKey: input.workspaceKey,
|
||||
}),
|
||||
];
|
||||
const hostRepository = new HostRepositoryWorkspace();
|
||||
const prepared = await hostRepository.prepare({
|
||||
attemptId: input.attemptId,
|
||||
piHomeFiles: makePiHomeFiles({
|
||||
api: env.AGENT_MODEL_API,
|
||||
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
|
||||
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
|
||||
model: env.AGENT_MODEL_NAME,
|
||||
provider: env.AGENT_MODEL_PROVIDER,
|
||||
}),
|
||||
});
|
||||
events.push(
|
||||
event(
|
||||
1,
|
||||
"runtime.preparing",
|
||||
prepared.created
|
||||
? "Isolated Zopu worktree created on the execution host"
|
||||
: "Isolated Zopu worktree recreated"
|
||||
)
|
||||
);
|
||||
const mounts = [
|
||||
{
|
||||
hostPath: prepared.checkoutPath,
|
||||
path: "/workspace/repository",
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
hostPath: prepared.sourceRepositoryPath,
|
||||
path: prepared.sourceRepositoryPath,
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
hostPath: prepared.piHomePath,
|
||||
path: "/home/zopu",
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
hostPath: prepared.toolsPath,
|
||||
path: "/opt/zopu-tools",
|
||||
readOnly: true,
|
||||
},
|
||||
] as const;
|
||||
const mountNext = async (index: number): Promise<void> => {
|
||||
const mount = mounts[index];
|
||||
if (!mount) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vm.mountFs({
|
||||
path: mount.path,
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: mount.hostPath,
|
||||
readOnly: mount.readOnly,
|
||||
}),
|
||||
readOnly: mount.readOnly,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw executionError(
|
||||
`Failed to mount ${mount.hostPath} at ${mount.path}: ${message}`,
|
||||
"HarnessFailed",
|
||||
false
|
||||
);
|
||||
}
|
||||
await mountNext(index + 1);
|
||||
};
|
||||
await mountNext(0);
|
||||
const { baseRevision } = prepared;
|
||||
events.push(
|
||||
event(2, "repository.ready", "Repository checkout is ready", {
|
||||
baseRevision,
|
||||
})
|
||||
);
|
||||
|
||||
const sessionId = `pi-${input.attemptId}`;
|
||||
await vm.openSession({
|
||||
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
|
||||
additionalInstructions:
|
||||
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
|
||||
agent: "pi",
|
||||
cwd: "/workspace/repository",
|
||||
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
|
||||
permissionPolicy: "allow_all",
|
||||
sessionId,
|
||||
});
|
||||
events.push(
|
||||
event(3, "harness.started", "Pi implementation session started")
|
||||
);
|
||||
const promptResult = await vm.prompt({
|
||||
content: [{ text: input.prompt, type: "text" }],
|
||||
idempotencyKey: input.attemptId,
|
||||
sessionId,
|
||||
});
|
||||
if (promptResult.stopReason !== "end_turn") {
|
||||
const reason =
|
||||
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
|
||||
throw executionError(
|
||||
`Pi stopped with ${promptResult.stopReason}`,
|
||||
reason,
|
||||
promptResult.stopReason === "max_tokens" ||
|
||||
promptResult.stopReason === "max_turn_requests"
|
||||
);
|
||||
}
|
||||
events.push(
|
||||
event(4, "harness.progress", "Pi implementation turn completed", {
|
||||
stopReason: promptResult.stopReason,
|
||||
})
|
||||
);
|
||||
|
||||
const collected = await HostRepositoryWorkspace.collect({
|
||||
attemptId: input.attemptId,
|
||||
baseRevision,
|
||||
checkoutPath: prepared.checkoutPath,
|
||||
});
|
||||
const { candidateRevision, changedFiles, diff } = collected;
|
||||
events.push(
|
||||
event(
|
||||
5,
|
||||
"repository.changed",
|
||||
`${changedFiles.length} changed file(s) collected`,
|
||||
{
|
||||
candidateRevision,
|
||||
}
|
||||
),
|
||||
event(6, "runtime.completed", "AgentOS execution completed")
|
||||
);
|
||||
|
||||
return {
|
||||
baseRevision,
|
||||
candidateRevision,
|
||||
changedFiles,
|
||||
diff,
|
||||
environmentId: input.workspaceKey,
|
||||
events,
|
||||
summary:
|
||||
changedFiles.length > 0
|
||||
? `Pi changed ${changedFiles.length} file(s)`
|
||||
: "Pi completed without repository changes",
|
||||
};
|
||||
} catch (error) {
|
||||
throw classifyRuntimeFailure(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelAgentOsAttempt = async (
|
||||
workspaceKey: string,
|
||||
attemptId: string
|
||||
) => {
|
||||
const env = parseAgentEnv(process.env);
|
||||
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
|
||||
const client = createClient<typeof runtimeRegistry>({
|
||||
disableMetadataLookup: true,
|
||||
encoding: "cbor",
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
});
|
||||
await client.workspace
|
||||
.getOrCreate([workspaceKey], {
|
||||
params: { token: env.RIVET_WORKSPACE_TOKEN },
|
||||
})
|
||||
.cancelPrompt({ sessionId: `pi-${attemptId}` });
|
||||
};
|
||||
274
packages/agents/src/runtime/host-repository.ts
Normal file
274
packages/agents/src/runtime/host-repository.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { PiHomeFiles } from "@code/primitives";
|
||||
|
||||
interface PrepareRepositoryInput {
|
||||
attemptId: string;
|
||||
piHomeFiles: PiHomeFiles;
|
||||
}
|
||||
|
||||
interface PreparedRepository {
|
||||
baseRevision: string;
|
||||
checkoutPath: string;
|
||||
created: boolean;
|
||||
piHomePath: string;
|
||||
sourceRepositoryPath: string;
|
||||
toolsPath: string;
|
||||
}
|
||||
|
||||
interface CollectRepositoryInput {
|
||||
attemptId: string;
|
||||
baseRevision: string;
|
||||
checkoutPath: string;
|
||||
}
|
||||
|
||||
interface CollectedRepository {
|
||||
candidateRevision: string;
|
||||
changedFiles: string[];
|
||||
diff: string;
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
const requireSuccess = (result: ProcessResult, operation: string): string => {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
};
|
||||
|
||||
const changedFilePath = (line: string): string => {
|
||||
const filePath = line.slice(3).trim();
|
||||
const renameSeparator = " -> ";
|
||||
const renameIndex = filePath.lastIndexOf(renameSeparator);
|
||||
return renameIndex === -1
|
||||
? filePath
|
||||
: filePath.slice(renameIndex + renameSeparator.length);
|
||||
};
|
||||
|
||||
const runProcess = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
cwd: string,
|
||||
env: Record<string, string> = {}
|
||||
): Promise<ProcessResult> => {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined
|
||||
)
|
||||
);
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...environment, ...env },
|
||||
});
|
||||
const stderr: Uint8Array[] = [];
|
||||
const stdout: Uint8Array[] = [];
|
||||
child.stderr.on("data", (chunk: Uint8Array) => stderr.push(chunk));
|
||||
child.stdout.on("data", (chunk: Uint8Array) => stdout.push(chunk));
|
||||
const [exitCode] = await once(child, "close");
|
||||
return {
|
||||
exitCode: typeof exitCode === "number" ? exitCode : 1,
|
||||
stderr: Buffer.concat(stderr).toString(),
|
||||
stdout: Buffer.concat(stdout).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const runGit = (cwd: string, args: readonly string[]) =>
|
||||
runProcess("git", args, cwd);
|
||||
|
||||
const pathExists = async (target: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export class HostRepositoryWorkspace {
|
||||
readonly #root: string;
|
||||
readonly #sourceRepositoryPath: string;
|
||||
readonly #installDependencies: boolean;
|
||||
|
||||
constructor(
|
||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
|
||||
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
|
||||
"/opt/zopu-source",
|
||||
installDependencies = true
|
||||
) {
|
||||
this.#root = root;
|
||||
this.#sourceRepositoryPath = sourceRepositoryPath;
|
||||
this.#installDependencies = installDependencies;
|
||||
}
|
||||
|
||||
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
|
||||
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
|
||||
throw new Error(
|
||||
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
|
||||
);
|
||||
}
|
||||
|
||||
const identity = createHash("sha256")
|
||||
.update(input.attemptId)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
const workspacePath = path.join(this.#root, identity);
|
||||
const checkoutPath = path.join(workspacePath, "repository");
|
||||
const toolsPath = path.join(workspacePath, "tools");
|
||||
const piHomePath = path.join(workspacePath, "home");
|
||||
const branch = `zopu/attempt-${identity}`;
|
||||
const created = !(await pathExists(path.join(checkoutPath, ".git")));
|
||||
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
if (!created) {
|
||||
requireSuccess(
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
checkoutPath,
|
||||
]),
|
||||
"Existing worktree removal"
|
||||
);
|
||||
}
|
||||
await rm(checkoutPath, { force: true, recursive: true });
|
||||
requireSuccess(
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"add",
|
||||
"-B",
|
||||
branch,
|
||||
checkoutPath,
|
||||
"HEAD",
|
||||
]),
|
||||
"Zopu worktree creation"
|
||||
);
|
||||
|
||||
const bunExecutable =
|
||||
process.env.BUN_EXECUTABLE ??
|
||||
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
|
||||
|
||||
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
|
||||
if (await pathExists(sourceEnvPath)) {
|
||||
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
|
||||
}
|
||||
if (this.#installDependencies) {
|
||||
requireSuccess(
|
||||
await runProcess(
|
||||
bunExecutable,
|
||||
["install", "--frozen-lockfile"],
|
||||
checkoutPath,
|
||||
{ CI: "1" }
|
||||
),
|
||||
"Workspace dependency installation"
|
||||
);
|
||||
}
|
||||
|
||||
const toolsBinPath = path.join(toolsPath, "bin");
|
||||
await mkdir(toolsBinPath, { recursive: true });
|
||||
const bunPath = path.join(toolsBinPath, "bun");
|
||||
await copyFile(bunExecutable, bunPath);
|
||||
await chmod(bunPath, 0o755);
|
||||
|
||||
const piAgentPath = path.join(piHomePath, ".pi", "agent");
|
||||
await mkdir(piAgentPath, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(piAgentPath, "models.json"),
|
||||
input.piHomeFiles.models
|
||||
);
|
||||
await writeFile(
|
||||
path.join(piAgentPath, "settings.json"),
|
||||
input.piHomeFiles.settings
|
||||
);
|
||||
|
||||
return {
|
||||
baseRevision: requireSuccess(
|
||||
await runGit(checkoutPath, ["rev-parse", "HEAD"]),
|
||||
"Base revision lookup"
|
||||
),
|
||||
checkoutPath,
|
||||
created,
|
||||
piHomePath,
|
||||
sourceRepositoryPath: this.#sourceRepositoryPath,
|
||||
toolsPath,
|
||||
};
|
||||
}
|
||||
|
||||
static async collect(
|
||||
input: CollectRepositoryInput
|
||||
): Promise<CollectedRepository> {
|
||||
const status = requireSuccess(
|
||||
await runGit(input.checkoutPath, ["status", "--porcelain"]),
|
||||
"Changed file lookup"
|
||||
);
|
||||
let diff = "";
|
||||
const changedFiles = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(changedFilePath);
|
||||
let candidateRevision = input.baseRevision;
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
requireSuccess(
|
||||
await runGit(input.checkoutPath, ["add", "-A"]),
|
||||
"Candidate staging"
|
||||
);
|
||||
diff = requireSuccess(
|
||||
await runGit(input.checkoutPath, [
|
||||
"diff",
|
||||
"--binary",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
input.baseRevision,
|
||||
]),
|
||||
"Diff collection"
|
||||
);
|
||||
const tree = requireSuccess(
|
||||
await runGit(input.checkoutPath, ["write-tree"]),
|
||||
"Candidate tree creation"
|
||||
);
|
||||
candidateRevision = requireSuccess(
|
||||
await runProcess(
|
||||
"git",
|
||||
[
|
||||
"commit-tree",
|
||||
tree,
|
||||
"-p",
|
||||
input.baseRevision,
|
||||
"-m",
|
||||
`Zopu candidate for ${input.attemptId}`,
|
||||
],
|
||||
input.checkoutPath,
|
||||
{
|
||||
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
|
||||
GIT_AUTHOR_NAME: "Zopu Agent",
|
||||
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
|
||||
GIT_COMMITTER_NAME: "Zopu Agent",
|
||||
}
|
||||
),
|
||||
"Candidate revision creation"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(input.checkoutPath, ["reset"]),
|
||||
"Candidate index reset"
|
||||
);
|
||||
}
|
||||
|
||||
return { candidateRevision, changedFiles, diff };
|
||||
}
|
||||
}
|
||||
572
packages/agents/src/tools/create-pr-for-issue.ts
Normal file
572
packages/agents/src/tools/create-pr-for-issue.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { SpawnSyncOptions } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import type { JsonValue } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { executeAgentOsAttempt } from "../runtime/agent-os";
|
||||
|
||||
/**
|
||||
* Direct Zopu chat tool that converts a Gitea issue into an AgentOS-implemented
|
||||
* pull request. It owns the entire issue → harness → push → PR pipeline so the
|
||||
* chat agent never needs (and must not use) shell mutation for this workflow.
|
||||
*/
|
||||
|
||||
// The host checkout the chat agent explores. Tea and Git resolve this repo's
|
||||
// Gitea connection; all Tea/Git commands run with this fixed cwd.
|
||||
const TRUSTED_REPO_PATH = "/Users/puter/Workspace/zopu/code";
|
||||
const REPO_SLUG = "puter/zopu-code";
|
||||
|
||||
// Bounded output so a verbose harness/CLI can never blow up a tool result.
|
||||
const MAX_OUTPUT = 4000;
|
||||
const MAX_ISSUE_BODY = 8000;
|
||||
const MAX_CHANGED_FILES = 200;
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
const PUSH_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
interface IssueDetails {
|
||||
readonly body: string;
|
||||
readonly title: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
interface ToolFailure {
|
||||
readonly [key: string]: JsonValue;
|
||||
readonly error: string;
|
||||
readonly issue: string;
|
||||
readonly stage: string;
|
||||
}
|
||||
|
||||
interface ToolSuccess {
|
||||
readonly [key: string]: JsonValue;
|
||||
readonly baseBranch: string;
|
||||
readonly changedFileCount: number;
|
||||
readonly changedFiles: string[];
|
||||
readonly headBranch: string;
|
||||
readonly issue: string;
|
||||
readonly issueNumber: number;
|
||||
readonly ok: true;
|
||||
readonly pullRequestUrl: string;
|
||||
readonly summary: string;
|
||||
}
|
||||
interface ToolAccepted {
|
||||
readonly [key: string]: JsonValue;
|
||||
readonly accepted: true;
|
||||
readonly backgroundId: string;
|
||||
readonly issue: string;
|
||||
readonly issueNumber: number;
|
||||
readonly message: string;
|
||||
readonly ok: true;
|
||||
}
|
||||
|
||||
const cap = (value: string, limit = MAX_OUTPUT): string =>
|
||||
value.length <= limit ? value : `${value.slice(0, limit)}…[truncated]`;
|
||||
|
||||
// Stable prefix for every background pipeline log line so operators can grep
|
||||
// for issue→PR outcomes independently of chat traffic.
|
||||
const LOG_PREFIX = "[create_pr_for_issue]";
|
||||
|
||||
/** Writes one stable, prefixed server log line for a background outcome. */
|
||||
const logBackground = (level: "info" | "error", message: string): void => {
|
||||
const line = `${LOG_PREFIX} ${message}`;
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
};
|
||||
|
||||
/** Logs a structured terminal pipeline result under the stable prefix. */
|
||||
const logPipelineResult = (
|
||||
backgroundId: string,
|
||||
result: ToolFailure | ToolSuccess
|
||||
): void => {
|
||||
if ("ok" in result && result.ok === true) {
|
||||
logBackground(
|
||||
"info",
|
||||
`background=${backgroundId} completed issue=#${result.issueNumber} stage=pr url=${result.pullRequestUrl} changedFiles=${result.changedFileCount}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const detail =
|
||||
"reason" in result && typeof result.reason === "string"
|
||||
? ` reason=${result.reason}`
|
||||
: "";
|
||||
logBackground(
|
||||
"error",
|
||||
`background=${backgroundId} failed issue=${result.issue} issueNumber=${result.issueNumber} stage=${result.stage}: ${result.error}${detail}`
|
||||
);
|
||||
};
|
||||
|
||||
/** Last-resort logger for an unexpected throw outside the pipeline's catch. */
|
||||
const logPipelineError = (backgroundId: string, error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logBackground("error", `background=${backgroundId} unexpected: ${message}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs one command as a direct child process. Arguments are passed to the
|
||||
* child verbatim — never through a shell — so issue titles, bodies, and
|
||||
* revisions cannot inject shell metacharacters.
|
||||
*/
|
||||
const run = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): CommandResult => {
|
||||
const spawnOptions: SpawnSyncOptions = {
|
||||
cwd: options.cwd ?? TRUSTED_REPO_PATH,
|
||||
env: { ...process.env, ...options.env },
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
|
||||
};
|
||||
const result = spawnSync(command, args, spawnOptions);
|
||||
return {
|
||||
status: result.status,
|
||||
stderr: cap((result.stderr ?? "").toString()),
|
||||
stdout: cap((result.stdout ?? "").toString()),
|
||||
};
|
||||
};
|
||||
|
||||
/** Extracts the issue number from an issue number, "#23", or a full issue URL. */
|
||||
const parseIssueNumber = (input: string): number | null => {
|
||||
// Prefer a /issues/<n> URL path segment so anchors like #comment-45 do not
|
||||
// hijack the number.
|
||||
const pathMatch = input.match(/\/issues\/(?<issueNumber>\d+)(?:[^/\d]|$)/iu);
|
||||
if (pathMatch?.groups?.issueNumber) {
|
||||
return Math.trunc(Number(pathMatch.groups.issueNumber));
|
||||
}
|
||||
const hashMatch = input.match(/#(?<issueNumber>\d+)\b/u);
|
||||
if (hashMatch?.groups?.issueNumber) {
|
||||
return Math.trunc(Number(hashMatch.groups.issueNumber));
|
||||
}
|
||||
const standalone = input.trim().match(/^\d+$/u);
|
||||
if (standalone) {
|
||||
return Math.trunc(Number(standalone[0]));
|
||||
}
|
||||
// Last resort: the trailing integer anywhere in the string.
|
||||
const value = Math.trunc(Number(input.match(/\d+/gu)?.at(-1)));
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readIssue = (issueNumber: number): IssueDetails | ToolFailure => {
|
||||
const result = run("tea", [
|
||||
"issues",
|
||||
String(issueNumber),
|
||||
"--fields",
|
||||
"index,title,body,url,state",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
error: `tea could not read issue #${issueNumber}.`,
|
||||
issue: String(issueNumber),
|
||||
issueNumber,
|
||||
stage: "read-issue",
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
let data: {
|
||||
body?: string;
|
||||
state?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
};
|
||||
try {
|
||||
data = JSON.parse(result.stdout) as typeof data;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Could not parse the issue payload from tea: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
issue: String(issueNumber),
|
||||
issueNumber,
|
||||
stage: "read-issue",
|
||||
stderr: result.stdout,
|
||||
};
|
||||
}
|
||||
const title = (data.title ?? "").trim();
|
||||
if (title.length === 0) {
|
||||
return {
|
||||
error: `Issue #${issueNumber} has no title.`,
|
||||
issue: String(issueNumber),
|
||||
issueNumber,
|
||||
stage: "read-issue",
|
||||
};
|
||||
}
|
||||
return {
|
||||
body: (data.body ?? "").trim(),
|
||||
title,
|
||||
url: (data.url ?? "").trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const buildPrompt = (issueNumber: number, issue: IssueDetails): string => {
|
||||
const body =
|
||||
issue.body.length > MAX_ISSUE_BODY
|
||||
? `${issue.body.slice(0, MAX_ISSUE_BODY)}…[issue body truncated]`
|
||||
: issue.body;
|
||||
return [
|
||||
`Resolve Gitea issue #${issueNumber} in this repository and ship a complete implementation.`,
|
||||
"",
|
||||
`## Issue #${issueNumber}: ${issue.title}`,
|
||||
"",
|
||||
body || "_(The issue provided no additional description.)_",
|
||||
"",
|
||||
"## Instructions",
|
||||
"- Read AGENTS.md and the relevant product/architecture specifications before changing code.",
|
||||
"- Implement exactly the change described by the issue with focused, correct edits.",
|
||||
"- Reuse existing conventions; do not introduce unrelated scope.",
|
||||
"- Run focused verification for your change before finishing.",
|
||||
"- Do NOT push, open a pull request, rewrite history, or modify the read-only base checkout.",
|
||||
" The orchestrator collects your working-tree changes, pushes a candidate branch, and opens the pull request after you finish.",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
/** Mirrors HostRepositoryWorkspace's worktree layout to locate the harness checkout. */
|
||||
const harnessCheckoutPath = (attemptId: string): string => {
|
||||
const root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces";
|
||||
const identity = createHash("sha256")
|
||||
.update(attemptId)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
return path.join(root, identity, "repository");
|
||||
};
|
||||
|
||||
const detectBaseBranch = (): string => {
|
||||
const result = run("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
|
||||
cwd: TRUSTED_REPO_PATH,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const ref = result.stdout.trim();
|
||||
const branch = ref.startsWith("origin/")
|
||||
? ref.slice("origin/".length)
|
||||
: ref;
|
||||
if (branch.length > 0) {
|
||||
return branch;
|
||||
}
|
||||
}
|
||||
return "master";
|
||||
};
|
||||
|
||||
const pushCandidate = (
|
||||
checkoutPath: string,
|
||||
candidateRevision: string,
|
||||
pushBranch: string
|
||||
): true | ToolFailure => {
|
||||
const result = run(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
checkoutPath,
|
||||
"push",
|
||||
"origin",
|
||||
`${candidateRevision}:refs/heads/${pushBranch}`,
|
||||
],
|
||||
{ cwd: checkoutPath, timeoutMs: PUSH_TIMEOUT_MS }
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
error: `Could not push candidate branch "${pushBranch}" from the harness worktree.`,
|
||||
issue: "",
|
||||
stage: "push",
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const findPullUrl = (head: string): string => {
|
||||
const result = run("tea", [
|
||||
"pulls",
|
||||
"list",
|
||||
"--state",
|
||||
"open",
|
||||
"--fields",
|
||||
"index,title,head,url",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const pulls = JSON.parse(result.stdout) as {
|
||||
head?: string;
|
||||
url?: string;
|
||||
}[];
|
||||
const match = pulls.find(
|
||||
(pull) => pull.head === head || (pull.head ?? "").endsWith(`:${head}`)
|
||||
);
|
||||
return (match?.url ?? "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const createPullRequest = (
|
||||
issueNumber: number,
|
||||
title: string,
|
||||
head: string,
|
||||
base: string
|
||||
): ToolFailure | { url: string } => {
|
||||
const prTitle = `Fix #${issueNumber}: ${title}`;
|
||||
const result = run("tea", [
|
||||
"pulls",
|
||||
"create",
|
||||
"--head",
|
||||
head,
|
||||
"--base",
|
||||
base,
|
||||
"--title",
|
||||
prTitle,
|
||||
"--description",
|
||||
`Fixes #${issueNumber}`,
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
error: `tea could not create the pull request for branch "${head}".`,
|
||||
issue: "",
|
||||
stage: "create-pr",
|
||||
stderr: result.stderr,
|
||||
stdout: result.stdout,
|
||||
};
|
||||
}
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
const urlMatch = combined.match(/https?:\/\/[^\s)]+\/pulls\/\d+/u);
|
||||
if (urlMatch) {
|
||||
return { url: urlMatch[0] };
|
||||
}
|
||||
// Fall back to listing open pulls and matching the head branch.
|
||||
const fallback = findPullUrl(head);
|
||||
if (fallback.length > 0) {
|
||||
return { url: fallback };
|
||||
}
|
||||
return {
|
||||
error:
|
||||
"The pull request was created, but its URL could not be captured from tea. Inspect open pulls in Gitea.",
|
||||
issue: "",
|
||||
stage: "create-pr",
|
||||
stdout: result.stdout,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the full issue → AgentOS → push → PR pipeline and returns a structured
|
||||
* terminal result. Used both by the background scheduler (chat path) and any
|
||||
* direct/internal caller that wants the structured outcome. Never rejects: every
|
||||
* failure is caught and returned as a {@link ToolFailure}.
|
||||
*/
|
||||
const runIssuePrPipeline = async (params: {
|
||||
readonly giteaUrl: string;
|
||||
readonly giteaToken: string | undefined;
|
||||
readonly issue: string;
|
||||
readonly issueNumber: number;
|
||||
readonly identifiers: {
|
||||
readonly attemptId: string;
|
||||
readonly headBranch: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceKey: string;
|
||||
};
|
||||
}): Promise<ToolFailure | ToolSuccess> => {
|
||||
const { giteaUrl, giteaToken, issue, issueNumber, identifiers } = params;
|
||||
try {
|
||||
const issueOrFailure = readIssue(issueNumber);
|
||||
if ("error" in issueOrFailure) {
|
||||
return issueOrFailure;
|
||||
}
|
||||
const issueDetails = issueOrFailure;
|
||||
|
||||
const baseBranch = detectBaseBranch();
|
||||
const prompt = buildPrompt(issueNumber, issueDetails);
|
||||
|
||||
// The fixed-checkout harness path ignores `auth`/`repositoryUrl` for
|
||||
// cloning (it always uses the mounted Zopu source worktree); they are
|
||||
// still supplied as valid schema values. GITEA_TOKEN is optional.
|
||||
const result = await executeAgentOsAttempt({
|
||||
attemptId: identifiers.attemptId,
|
||||
auth: {
|
||||
credential: giteaToken ?? "unused-by-fixed-checkout",
|
||||
provider: "gitea",
|
||||
serverUrl: giteaUrl,
|
||||
},
|
||||
baseBranch,
|
||||
prompt,
|
||||
repositoryUrl: `${giteaUrl}/${REPO_SLUG}`,
|
||||
runId: identifiers.runId,
|
||||
workId: `issue-${issueNumber}`,
|
||||
workspaceKey: identifiers.workspaceKey,
|
||||
});
|
||||
|
||||
const changedFiles = result.changedFiles.slice(0, MAX_CHANGED_FILES);
|
||||
if (
|
||||
result.changedFiles.length === 0 ||
|
||||
result.candidateRevision === result.baseRevision
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"The AgentOS session completed without producing any repository changes, so there is nothing to turn into a pull request.",
|
||||
issue,
|
||||
issueNumber,
|
||||
stage: "harness",
|
||||
summary: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
const checkoutPath = harnessCheckoutPath(identifiers.attemptId);
|
||||
const pushed = pushCandidate(
|
||||
checkoutPath,
|
||||
result.candidateRevision,
|
||||
identifiers.headBranch
|
||||
);
|
||||
if (pushed !== true) {
|
||||
return {
|
||||
...pushed,
|
||||
attemptId: identifiers.attemptId,
|
||||
baseRevision: result.baseRevision,
|
||||
candidateRevision: result.candidateRevision,
|
||||
headBranch: identifiers.headBranch,
|
||||
issue,
|
||||
issueNumber,
|
||||
workspaceKey: identifiers.workspaceKey,
|
||||
};
|
||||
}
|
||||
|
||||
const pr = createPullRequest(
|
||||
issueNumber,
|
||||
issueDetails.title,
|
||||
identifiers.headBranch,
|
||||
baseBranch
|
||||
);
|
||||
if ("error" in pr) {
|
||||
return {
|
||||
...pr,
|
||||
attemptId: identifiers.attemptId,
|
||||
baseRevision: result.baseRevision,
|
||||
candidateRevision: result.candidateRevision,
|
||||
headBranch: identifiers.headBranch,
|
||||
issue,
|
||||
issueNumber,
|
||||
workspaceKey: identifiers.workspaceKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
baseBranch,
|
||||
changedFileCount: result.changedFiles.length,
|
||||
changedFiles,
|
||||
headBranch: identifiers.headBranch,
|
||||
issue,
|
||||
issueNumber,
|
||||
ok: true,
|
||||
pullRequestUrl: pr.url,
|
||||
summary: result.summary,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unexpected failure";
|
||||
const reason =
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"reason" in error &&
|
||||
typeof (error as { reason: unknown }).reason === "string"
|
||||
? (error as { reason: string }).reason
|
||||
: undefined;
|
||||
return {
|
||||
error: message,
|
||||
issue,
|
||||
issueNumber,
|
||||
...(reason ? { reason } : {}),
|
||||
stage: "harness",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createCreatePrForIssueTool = (
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const agentEnv = parseAgentEnv(runtimeEnv);
|
||||
const giteaUrl = agentEnv.GITEA_URL;
|
||||
|
||||
return defineTool({
|
||||
description:
|
||||
'Validate a Gitea issue number or URL immediately, then return an accepted acknowledgement right away while an AgentOS coding session implements the issue, pushes a candidate branch, and opens a PR titled "Fix #N: <title>" with body "Fixes #N" in the background. Pass a Gitea issue number or full issue URL. The created PR URL and any failure are logged to the server log with the "[create_pr_for_issue]" prefix rather than returned to chat; malformed input returns a parse failure immediately.',
|
||||
input: v.object({
|
||||
issue: v.pipe(
|
||||
v.string(),
|
||||
v.description(
|
||||
"A Gitea issue number like 23, or a full issue URL such as https://git.openputer.com/puter/zopu-code/issues/23."
|
||||
)
|
||||
),
|
||||
}),
|
||||
name: "create_pr_for_issue",
|
||||
run({ input }): ToolFailure | ToolAccepted {
|
||||
const issueRef = input.issue.trim();
|
||||
const issueNumber = parseIssueNumber(issueRef);
|
||||
if (issueNumber === null) {
|
||||
return {
|
||||
error: `Could not parse an issue number from "${input.issue}". Provide a Gitea issue number or URL.`,
|
||||
issue: input.issue,
|
||||
stage: "parse",
|
||||
};
|
||||
}
|
||||
|
||||
// Background identifiers are generated up front so the accepted
|
||||
// response can reference them and the pipeline can reuse them.
|
||||
const backgroundId = randomUUID();
|
||||
const attemptId = `create-pr-${issueNumber}-${randomUUID()}`;
|
||||
const workspaceKey = `issue-${issueNumber}-pr-${randomUUID()}`;
|
||||
const runId = `issue-pr-${randomUUID()}`;
|
||||
const shortId = randomUUID().slice(0, 8);
|
||||
const headBranch = `zopu/issue-${issueNumber}-${shortId}`;
|
||||
|
||||
// Schedule the full issue → AgentOS → push → PR pipeline on the next
|
||||
// microtask boundary so this turn returns immediately. The pipeline
|
||||
// catches its own failures and logs a stable prefixed line; the IIFE
|
||||
// never rejects, so the originating chat turn cannot fail.
|
||||
queueMicrotask(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await runIssuePrPipeline({
|
||||
giteaToken: runtimeEnv.GITEA_TOKEN,
|
||||
giteaUrl,
|
||||
identifiers: { attemptId, headBranch, runId, workspaceKey },
|
||||
issue: input.issue,
|
||||
issueNumber,
|
||||
});
|
||||
logPipelineResult(backgroundId, result);
|
||||
} catch (error) {
|
||||
logPipelineError(backgroundId, error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
backgroundId,
|
||||
issue: input.issue,
|
||||
issueNumber,
|
||||
message: `Issue #${issueNumber} accepted. An AgentOS session will implement it, push branch "${headBranch}", and open a pull request in the background. The created PR URL and any failure are logged with the "${LOG_PREFIX}" prefix.`,
|
||||
ok: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["bun"] },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist"]
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -23,13 +23,13 @@
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"heroui-native": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-native": "0.86.0",
|
||||
"react-native": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@types/react": "~19.2.17",
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.VITE_AUTH_URL,
|
||||
baseURL: typeof window === "undefined" ? undefined : window.location.origin,
|
||||
plugins: [convexClient()],
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["convex/**/*.ts"],
|
||||
"rules": {
|
||||
"unicorn/filename-case": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
# Convex Backend
|
||||
|
||||
Convex is the only application API used by web, desktop, and mobile clients.
|
||||
The active Slice 1 backend is intentionally limited to tenancy, projects,
|
||||
conversation turns, Signals, Work, and Flue's required persistence adapter.
|
||||
Convex is the only application API used by web, desktop, and mobile clients. The active backend covers the durable control plane through Slice 4: tenancy, projects, conversation turns, Signals, Work planning, approved Design slices, simulated Runs and Attempts, artifacts/delivery metadata, and Flue persistence.
|
||||
|
||||
## Domain relations
|
||||
|
||||
@@ -11,6 +9,9 @@ conversation turns, Signals, Work, and Flue's required persistence adapter.
|
||||
- `conversations`, `conversationTurns`, `conversationMessages`, and `conversationAttachments` own chat admission and reactive history.
|
||||
- `signals`, `signalConstraints`, and `signalSources` preserve structured intent and exact evidence.
|
||||
- `works`, `signalWorkAttachments`, and `workEvents` own proposed Work and provenance.
|
||||
- `workDefinitions`, `workQuestions`, and `workApprovals` own versioned outcome contracts and approvals.
|
||||
- `designPackets` and `workSlices` preserve versioned implementation intent and executable slice order.
|
||||
- `workRuns`, `workAttempts`, `workAttemptEvents`, and `resolverDecisions` own bounded simulated execution and recovery.
|
||||
- `workArtifacts` and `workDeliveries` preserve evidence and delivery metadata without performing Git or sandbox work.
|
||||
|
||||
The `flue*` tables are infrastructure tables required by Flue's persistence
|
||||
contract. They are deliberately separate from the product relations.
|
||||
The `flue*` tables are infrastructure tables required by Flue's persistence contract. They are deliberately separate from the product relations.
|
||||
|
||||
19
packages/backend/convex/_generated/api.d.ts
vendored
19
packages/backend/convex/_generated/api.d.ts
vendored
@@ -11,7 +11,11 @@
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authz from "../authz.js";
|
||||
import type * as conversationMessages from "../conversationMessages.js";
|
||||
import type * as conversationProjections from "../conversationProjections.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as fluePersistence from "../fluePersistence.js";
|
||||
import type * as gitConnectionData from "../gitConnectionData.js";
|
||||
import type * as gitConnections from "../gitConnections.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as organizations from "../organizations.js";
|
||||
@@ -19,6 +23,11 @@ import type * as privateData from "../privateData.js";
|
||||
import type * as projects from "../projects.js";
|
||||
import type * as publicGit from "../publicGit.js";
|
||||
import type * as signalRouting from "../signalRouting.js";
|
||||
import type * as workArtifacts from "../workArtifacts.js";
|
||||
import type * as workExecution from "../workExecution.js";
|
||||
import type * as workExecutionAgent from "../workExecutionAgent.js";
|
||||
import type * as workExecutionWorkflow from "../workExecutionWorkflow.js";
|
||||
import type * as workPlanning from "../workPlanning.js";
|
||||
import type * as works from "../works.js";
|
||||
|
||||
import type {
|
||||
@@ -31,7 +40,11 @@ declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
authz: typeof authz;
|
||||
conversationMessages: typeof conversationMessages;
|
||||
conversationProjections: typeof conversationProjections;
|
||||
crons: typeof crons;
|
||||
fluePersistence: typeof fluePersistence;
|
||||
gitConnectionData: typeof gitConnectionData;
|
||||
gitConnections: typeof gitConnections;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
organizations: typeof organizations;
|
||||
@@ -39,6 +52,11 @@ declare const fullApi: ApiFromModules<{
|
||||
projects: typeof projects;
|
||||
publicGit: typeof publicGit;
|
||||
signalRouting: typeof signalRouting;
|
||||
workArtifacts: typeof workArtifacts;
|
||||
workExecution: typeof workExecution;
|
||||
workExecutionAgent: typeof workExecutionAgent;
|
||||
workExecutionWorkflow: typeof workExecutionWorkflow;
|
||||
workPlanning: typeof workPlanning;
|
||||
works: typeof works;
|
||||
}>;
|
||||
|
||||
@@ -70,4 +88,5 @@ export declare const internal: FilterApi<
|
||||
|
||||
export declare const components: {
|
||||
betterAuth: import("@convex-dev/better-auth/_generated/component.js").ComponentApi<"betterAuth">;
|
||||
workflow: import("@convex-dev/workflow/_generated/component.js").ComponentApi<"workflow">;
|
||||
};
|
||||
|
||||
@@ -25,10 +25,14 @@ import type { DataModel } from "./dataModel.js";
|
||||
* Typesafe environment variables declared in `convex.config.ts`.
|
||||
*/
|
||||
type Env = {
|
||||
readonly AGENT_BACKEND_URL: string | undefined;
|
||||
readonly FLUE_DB_TOKEN: string;
|
||||
readonly FLUE_URL: string | undefined;
|
||||
readonly GITEA_TOKEN: string | undefined;
|
||||
readonly GITEA_URL: string | undefined;
|
||||
readonly GITHUB_CLIENT_ID: string | undefined;
|
||||
readonly GITHUB_CLIENT_SECRET: string | undefined;
|
||||
readonly GIT_CREDENTIAL_ENCRYPTION_KEY: string | undefined;
|
||||
readonly NATIVE_APP_URL: string | undefined;
|
||||
readonly SITE_URL: string;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||
|
||||
const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
betterAuth({
|
||||
advanced: { useSecureCookies: siteUrl.startsWith("https://") },
|
||||
baseURL: env.CONVEX_SITE_URL,
|
||||
database: authComponent.adapter(ctx),
|
||||
emailAndPassword: {
|
||||
@@ -30,6 +31,15 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
jwksRotateOnTokenGenerationError: true,
|
||||
}),
|
||||
],
|
||||
socialProviders:
|
||||
env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
trustedOrigins: [siteUrl, nativeAppUrl, "exp://"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { anyApi, makeFunctionReference } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import schema from "./schema";
|
||||
@@ -15,6 +15,22 @@ const api = anyApi;
|
||||
const identityA = { tokenIdentifier: "https://convex.test|user-a" };
|
||||
const identityB = { tokenIdentifier: "https://convex.test|user-b" };
|
||||
const newTest = () => convexTest({ modules, schema });
|
||||
const markProcessingRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: string; attempt: number; leaseOwner: string },
|
||||
boolean
|
||||
>("conversationMessages:markProcessing");
|
||||
const failTurnRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
turnId: string;
|
||||
attempt: number;
|
||||
leaseOwner: string;
|
||||
error: string;
|
||||
retry: boolean;
|
||||
},
|
||||
boolean
|
||||
>("conversationMessages:failTurn");
|
||||
|
||||
const ensureOrg = async (
|
||||
t: ReturnType<typeof newTest>,
|
||||
@@ -109,4 +125,47 @@ describe("conversationMessages", () => {
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
});
|
||||
|
||||
test("fences stale dispatch failures after admission", async () => {
|
||||
const t = newTest();
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
const sent = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.send, {
|
||||
clientRequestId: "request-fenced",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: "Keep only the admitted submission",
|
||||
});
|
||||
|
||||
expect(
|
||||
await t.mutation(markProcessingRef, {
|
||||
attempt: 1,
|
||||
leaseOwner: "worker-1",
|
||||
turnId: sent.turnId,
|
||||
})
|
||||
).toBe(true);
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.patch(sent.turnId, {
|
||||
leaseExpiresAt: undefined,
|
||||
leaseOwner: undefined,
|
||||
status: "running",
|
||||
submissionId: "submission-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
await t.mutation(failTurnRef, {
|
||||
attempt: 1,
|
||||
error: "lost 202",
|
||||
leaseOwner: "worker-1",
|
||||
retry: true,
|
||||
turnId: sent.turnId,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(await t.run((ctx) => ctx.db.get(sent.turnId))).toMatchObject({
|
||||
status: "running",
|
||||
submissionId: "submission-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,18 +31,23 @@ const getTurnRef = makeFunctionReference<
|
||||
>("conversationMessages:getTurn");
|
||||
const markProcessingRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns"> },
|
||||
{
|
||||
turnId: Id<"conversationTurns">;
|
||||
attempt: number;
|
||||
leaseOwner: string;
|
||||
},
|
||||
boolean
|
||||
>("conversationMessages:markProcessing");
|
||||
const completeTurnRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns">; submissionId: string; text: string },
|
||||
null
|
||||
>("conversationMessages:completeTurn");
|
||||
const failTurnRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns">; error: string; retry: boolean },
|
||||
null
|
||||
{
|
||||
turnId: Id<"conversationTurns">;
|
||||
attempt: number;
|
||||
leaseOwner: string;
|
||||
error: string;
|
||||
retry: boolean;
|
||||
},
|
||||
boolean
|
||||
>("conversationMessages:failTurn");
|
||||
|
||||
export const generateUploadUrl = mutation({
|
||||
@@ -57,16 +62,16 @@ export const generateUploadUrl = mutation({
|
||||
|
||||
export const send = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
rawText: v.string(),
|
||||
images: v.array(
|
||||
v.object({
|
||||
storageId: v.id("_storage"),
|
||||
filename: v.optional(v.string()),
|
||||
mimeType: v.string(),
|
||||
storageId: v.id("_storage"),
|
||||
})
|
||||
),
|
||||
organizationId: v.id("organizations"),
|
||||
rawText: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
@@ -115,6 +120,7 @@ export const send = mutation({
|
||||
.first();
|
||||
const ordinal = (lastMessage?.ordinal ?? -1) + 1;
|
||||
const turnId = await ctx.db.insert("conversationTurns", {
|
||||
attemptNumber: 1,
|
||||
clientRequestId: args.clientRequestId,
|
||||
conversationId: conversation._id,
|
||||
createdAt,
|
||||
@@ -233,59 +239,57 @@ export const getTurn = internalQuery({
|
||||
});
|
||||
|
||||
export const markProcessing = internalMutation({
|
||||
args: { turnId: v.id("conversationTurns") },
|
||||
args: {
|
||||
attempt: v.number(),
|
||||
leaseOwner: v.string(),
|
||||
turnId: v.id("conversationTurns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<boolean> => {
|
||||
const turn = await ctx.db.get(args.turnId);
|
||||
if (!turn || turn.status === "completed") {
|
||||
if (
|
||||
!turn ||
|
||||
turn.status !== "queued" ||
|
||||
(turn.attemptNumber ?? 1) !== args.attempt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch(turn._id, { error: undefined, status: "processing" });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const completeTurn = internalMutation({
|
||||
args: {
|
||||
turnId: v.id("conversationTurns"),
|
||||
submissionId: v.string(),
|
||||
text: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const assistant = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", args.turnId).eq("role", "assistant")
|
||||
)
|
||||
.unique();
|
||||
if (assistant) {
|
||||
await ctx.db.patch(assistant._id, { content: args.text });
|
||||
}
|
||||
await ctx.db.patch(args.turnId, {
|
||||
completedAt: Date.now(),
|
||||
await ctx.db.patch(turn._id, {
|
||||
error: undefined,
|
||||
status: "completed",
|
||||
submissionId: args.submissionId,
|
||||
leaseExpiresAt: Date.now() + 60_000,
|
||||
leaseOwner: args.leaseOwner,
|
||||
status: "dispatching",
|
||||
});
|
||||
return null;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const failTurn = internalMutation({
|
||||
args: {
|
||||
turnId: v.id("conversationTurns"),
|
||||
attempt: v.number(),
|
||||
error: v.string(),
|
||||
leaseOwner: v.string(),
|
||||
retry: v.boolean(),
|
||||
turnId: v.id("conversationTurns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
handler: async (ctx, args): Promise<boolean> => {
|
||||
const turn = await ctx.db.get(args.turnId);
|
||||
if (turn && turn.status !== "completed") {
|
||||
if (
|
||||
!turn ||
|
||||
turn.status !== "dispatching" ||
|
||||
turn.leaseOwner !== args.leaseOwner ||
|
||||
(turn.attemptNumber ?? 1) !== args.attempt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch(turn._id, {
|
||||
attemptNumber: args.retry ? args.attempt + 1 : args.attempt,
|
||||
completedAt: args.retry ? undefined : Date.now(),
|
||||
error: args.error,
|
||||
leaseExpiresAt: undefined,
|
||||
leaseOwner: undefined,
|
||||
status: args.retry ? "queued" : "failed",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -298,12 +302,17 @@ const toBase64 = (buffer: ArrayBuffer): string => {
|
||||
};
|
||||
|
||||
export const runTurn = internalAction({
|
||||
args: { turnId: v.id("conversationTurns"), attempt: v.number() },
|
||||
args: { attempt: v.number(), turnId: v.id("conversationTurns") },
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const leaseOwner = `conversation:${args.turnId}:${args.attempt}`;
|
||||
const turn = await ctx.runQuery(getTurnRef, { turnId: args.turnId });
|
||||
if (
|
||||
!turn ||
|
||||
!(await ctx.runMutation(markProcessingRef, { turnId: args.turnId }))
|
||||
!(await ctx.runMutation(markProcessingRef, {
|
||||
attempt: args.attempt,
|
||||
leaseOwner,
|
||||
turnId: args.turnId,
|
||||
}))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -331,7 +340,6 @@ export const runTurn = internalAction({
|
||||
`agents/zopu/${encodeURIComponent(String(turn.organizationId))}`,
|
||||
`${flueUrl.replace(/\/+$/u, "")}/`
|
||||
);
|
||||
endpoint.searchParams.set("wait", "result");
|
||||
const response = await fetch(endpoint, {
|
||||
body: JSON.stringify({ images, message: turn.user.content }),
|
||||
headers: {
|
||||
@@ -339,37 +347,29 @@ export const runTurn = internalAction({
|
||||
"content-type": "application/json",
|
||||
"x-zopu-organization-id": String(turn.organizationId),
|
||||
"x-zopu-request-id": turn.turn.clientRequestId,
|
||||
"x-zopu-turn-id": String(args.turnId),
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
!response.ok ||
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("submissionId" in payload) ||
|
||||
typeof payload.submissionId !== "string" ||
|
||||
!("result" in payload) ||
|
||||
typeof payload.result !== "object" ||
|
||||
payload.result === null ||
|
||||
!("text" in payload.result) ||
|
||||
typeof payload.result.text !== "string"
|
||||
) {
|
||||
throw new Error(`Flue turn failed (${response.status})`);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Flue admission failed (${response.status}): ${await response.text().catch(() => "")}`
|
||||
);
|
||||
}
|
||||
const admitted = await ctx.runQuery(getTurnRef, { turnId: args.turnId });
|
||||
if (admitted?.turn.submissionId === undefined) {
|
||||
throw new Error("Flue admission did not bind the product turn");
|
||||
}
|
||||
await ctx.runMutation(completeTurnRef, {
|
||||
submissionId: payload.submissionId,
|
||||
text: payload.result.text,
|
||||
turnId: args.turnId,
|
||||
});
|
||||
} catch (error) {
|
||||
const retry = args.attempt < MAX_ATTEMPTS;
|
||||
await ctx.runMutation(failTurnRef, {
|
||||
const failed = await ctx.runMutation(failTurnRef, {
|
||||
attempt: args.attempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
leaseOwner,
|
||||
retry,
|
||||
turnId: args.turnId,
|
||||
});
|
||||
if (retry) {
|
||||
if (failed && retry) {
|
||||
await ctx.scheduler.runAfter(args.attempt * 1000, runTurnRef, {
|
||||
attempt: args.attempt + 1,
|
||||
turnId: args.turnId,
|
||||
@@ -379,3 +379,42 @@ export const runTurn = internalAction({
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const reconcileExpiredTurns = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ reconciled: number }> => {
|
||||
const expired = await ctx.db
|
||||
.query("conversationTurns")
|
||||
.withIndex("by_status_and_leaseExpiresAt", (q) =>
|
||||
q.eq("status", "dispatching").lt("leaseExpiresAt", Date.now())
|
||||
)
|
||||
.collect();
|
||||
for (const turn of expired) {
|
||||
if (turn.submissionId !== undefined) {
|
||||
await ctx.db.patch(turn._id, {
|
||||
leaseExpiresAt: undefined,
|
||||
leaseOwner: undefined,
|
||||
status: "running",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const attempt = turn.attemptNumber ?? 1;
|
||||
const retry = attempt < MAX_ATTEMPTS;
|
||||
await ctx.db.patch(turn._id, {
|
||||
attemptNumber: retry ? attempt + 1 : attempt,
|
||||
completedAt: retry ? undefined : Date.now(),
|
||||
error: "Conversation worker lease expired",
|
||||
leaseExpiresAt: undefined,
|
||||
leaseOwner: undefined,
|
||||
status: retry ? "queued" : "failed",
|
||||
});
|
||||
if (retry) {
|
||||
await ctx.scheduler.runAfter(0, runTurnRef, {
|
||||
attempt: attempt + 1,
|
||||
turnId: turn._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { reconciled: expired.length };
|
||||
},
|
||||
});
|
||||
|
||||
145
packages/backend/convex/conversationProjections.ts
Normal file
145
packages/backend/convex/conversationProjections.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
|
||||
interface ProjectionContext {
|
||||
readonly assistantMessageId: Id<"conversationMessages"> | null;
|
||||
readonly turnId: Id<"conversationTurns"> | null;
|
||||
}
|
||||
|
||||
const parseStringField = (record: unknown, key: string): string | undefined => {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (record as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
|
||||
const resolveContext = async (
|
||||
ctx: MutationCtx,
|
||||
submissionId: string | undefined
|
||||
): Promise<ProjectionContext> => {
|
||||
if (submissionId === undefined) {
|
||||
return { assistantMessageId: null, turnId: null };
|
||||
}
|
||||
const turn = await ctx.db
|
||||
.query("conversationTurns")
|
||||
.withIndex("by_submissionId", (q) => q.eq("submissionId", submissionId))
|
||||
.unique();
|
||||
if (turn === null) {
|
||||
return { assistantMessageId: null, turnId: null };
|
||||
}
|
||||
const assistant = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", turn._id).eq("role", "assistant")
|
||||
)
|
||||
.unique();
|
||||
return {
|
||||
assistantMessageId: assistant?._id ?? null,
|
||||
turnId: turn._id,
|
||||
};
|
||||
};
|
||||
|
||||
const terminalError = (record: unknown): string => {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return "Flue submission failed";
|
||||
}
|
||||
const value = (record as Record<string, unknown>).error;
|
||||
if (typeof value === "string") {
|
||||
return value.slice(0, 2000);
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const { message } = value as Record<string, unknown>;
|
||||
if (typeof message === "string") {
|
||||
return message.slice(0, 2000);
|
||||
}
|
||||
}
|
||||
return "Flue submission failed";
|
||||
};
|
||||
|
||||
/**
|
||||
* Project the small product view from Flue's canonical conversation stream.
|
||||
* The raw stream remains authoritative; this function only updates the existing
|
||||
* assistant message and turn row that the web already reads.
|
||||
*/
|
||||
export const projectConversationRecords = async (
|
||||
ctx: MutationCtx,
|
||||
recordsJson: string,
|
||||
submissionId: string | undefined
|
||||
): Promise<void> => {
|
||||
let records: unknown[];
|
||||
try {
|
||||
const parsed = JSON.parse(recordsJson) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
records = parsed;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const projection = await resolveContext(ctx, submissionId);
|
||||
if (projection.turnId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const type = parseStringField(record, "type");
|
||||
if (type === "assistant_text_delta" && projection.assistantMessageId) {
|
||||
const delta = parseStringField(record, "delta");
|
||||
if (delta !== undefined) {
|
||||
const assistant = await ctx.db.get(projection.assistantMessageId);
|
||||
if (assistant !== null) {
|
||||
await ctx.db.patch(assistant._id, {
|
||||
content: `${assistant.content}${delta}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type !== "submission_settled") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const turn = await ctx.db.get(projection.turnId);
|
||||
if (
|
||||
turn === null ||
|
||||
turn.status === "completed" ||
|
||||
turn.status === "failed" ||
|
||||
turn.status === "aborted"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const outcome = parseStringField(record, "outcome");
|
||||
if (outcome === "completed" && projection.assistantMessageId) {
|
||||
const result =
|
||||
typeof record === "object" && record !== null
|
||||
? (record as Record<string, unknown>).result
|
||||
: undefined;
|
||||
const text =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as Record<string, unknown>).text
|
||||
: undefined;
|
||||
if (typeof text === "string") {
|
||||
await ctx.db.patch(projection.assistantMessageId, { content: text });
|
||||
}
|
||||
}
|
||||
|
||||
let status: "aborted" | "completed" | "failed" = "failed";
|
||||
if (outcome === "completed") {
|
||||
status = "completed";
|
||||
} else if (outcome === "aborted") {
|
||||
status = "aborted";
|
||||
}
|
||||
|
||||
await ctx.db.patch(turn._id, {
|
||||
completedAt: Date.now(),
|
||||
error: outcome === "failed" ? terminalError(record) : undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
leaseOwner: undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,23 @@
|
||||
import betterAuth from "@convex-dev/better-auth/convex.config";
|
||||
import workflow from "@convex-dev/workflow/convex.config.js";
|
||||
import { defineApp } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const app = defineApp({
|
||||
env: {
|
||||
NATIVE_APP_URL: v.optional(v.string()),
|
||||
SITE_URL: v.string(),
|
||||
AGENT_BACKEND_URL: v.optional(v.string()),
|
||||
FLUE_DB_TOKEN: v.string(),
|
||||
FLUE_URL: v.optional(v.string()),
|
||||
GITEA_URL: v.optional(v.string()),
|
||||
GITEA_TOKEN: v.optional(v.string()),
|
||||
GITEA_URL: v.optional(v.string()),
|
||||
GITHUB_CLIENT_ID: v.optional(v.string()),
|
||||
GITHUB_CLIENT_SECRET: v.optional(v.string()),
|
||||
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
|
||||
NATIVE_APP_URL: v.optional(v.string()),
|
||||
SITE_URL: v.string(),
|
||||
},
|
||||
});
|
||||
app.use(betterAuth);
|
||||
app.use(workflow);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -7,6 +7,11 @@ const reconcileRef = makeFunctionReference<
|
||||
Record<string, never>,
|
||||
{ reconciled: number } | null
|
||||
>("workExecution:reconcileExpiredAttempts");
|
||||
const reconcileConversationTurnsRef = makeFunctionReference<
|
||||
"mutation",
|
||||
Record<string, never>,
|
||||
{ reconciled: number }
|
||||
>("conversationMessages:reconcileExpiredTurns");
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
@@ -15,5 +20,10 @@ crons.interval(
|
||||
{ seconds: 30 },
|
||||
reconcileRef
|
||||
);
|
||||
crons.interval(
|
||||
"reconcile expired conversation turns",
|
||||
{ seconds: 30 },
|
||||
reconcileConversationTurnsRef
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
189
packages/backend/convex/fluePersistence.test.ts
Normal file
189
packages/backend/convex/fluePersistence.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
const token = env.FLUE_DB_TOKEN;
|
||||
|
||||
describe("Flue Convex persistence", () => {
|
||||
test("admits idempotently and claims only the session head", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const firstInput = {
|
||||
acceptedAt: 1,
|
||||
inputJson: '{"message":"first"}',
|
||||
kind: "dispatch" as const,
|
||||
sessionKey: "agent/instance/default",
|
||||
submissionId: "dispatch-1",
|
||||
};
|
||||
const first = await t.mutation(api.fluePersistence.admitSubmission, {
|
||||
input: firstInput,
|
||||
token,
|
||||
});
|
||||
const replay = await t.mutation(api.fluePersistence.admitSubmission, {
|
||||
input: firstInput,
|
||||
token,
|
||||
});
|
||||
expect(first.kind).toBe("submission");
|
||||
expect(replay.kind).toBe("retained_receipt");
|
||||
|
||||
await t.mutation(api.fluePersistence.admitSubmission, {
|
||||
input: {
|
||||
...firstInput,
|
||||
acceptedAt: 2,
|
||||
inputJson: '{"message":"second"}',
|
||||
submissionId: "dispatch-2",
|
||||
},
|
||||
token,
|
||||
});
|
||||
await t.mutation(api.fluePersistence.markSubmissionCanonicalReady, {
|
||||
submissionId: "dispatch-1",
|
||||
token,
|
||||
});
|
||||
await t.mutation(api.fluePersistence.markSubmissionCanonicalReady, {
|
||||
submissionId: "dispatch-2",
|
||||
token,
|
||||
});
|
||||
const secondClaim = await t.mutation(api.fluePersistence.claimSubmission, {
|
||||
attemptId: "attempt-2",
|
||||
leaseExpiresAt: 100,
|
||||
ownerId: "owner",
|
||||
submissionId: "dispatch-2",
|
||||
token,
|
||||
});
|
||||
expect(secondClaim).toBeNull();
|
||||
const firstClaim = await t.mutation(api.fluePersistence.claimSubmission, {
|
||||
attemptId: "attempt-1",
|
||||
leaseExpiresAt: 100,
|
||||
ownerId: "owner",
|
||||
submissionId: "dispatch-1",
|
||||
token,
|
||||
});
|
||||
expect(firstClaim).toMatchObject({
|
||||
attemptId: "attempt-1",
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("binds a direct admission to the product turn atomically", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const organizationId = await t.run(async (ctx) =>
|
||||
ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user-1",
|
||||
kind: "personal",
|
||||
name: "Test",
|
||||
})
|
||||
);
|
||||
const conversationId = await t.run(async (ctx) =>
|
||||
ctx.db.insert("conversations", { createdAt: 1, organizationId })
|
||||
);
|
||||
const turnId = await t.run(async (ctx) =>
|
||||
ctx.db.insert("conversationTurns", {
|
||||
attemptNumber: 1,
|
||||
clientRequestId: "request-1",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
leaseExpiresAt: 100,
|
||||
leaseOwner: "worker",
|
||||
status: "dispatching",
|
||||
})
|
||||
);
|
||||
|
||||
await t.mutation(api.fluePersistence.admitSubmission, {
|
||||
clientRequestId: "request-1",
|
||||
input: {
|
||||
acceptedAt: 1,
|
||||
chunksJson: "[]",
|
||||
inputJson: '{"kind":"direct"}',
|
||||
kind: "direct",
|
||||
sessionKey: "agent/instance/default",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
token,
|
||||
turnId,
|
||||
});
|
||||
const turn = await t.run((ctx) => ctx.db.get(turnId));
|
||||
expect(turn).toMatchObject({
|
||||
status: "running",
|
||||
submissionId: "submission-1",
|
||||
});
|
||||
expect(turn).not.toHaveProperty("leaseExpiresAt");
|
||||
expect(turn).not.toHaveProperty("leaseOwner");
|
||||
});
|
||||
|
||||
test("fences stale conversation producers and conflicting attachments", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
await t.mutation(api.fluePersistence.createConversationStream, {
|
||||
identity: { agentName: "work-planner", instanceId: "org-1" },
|
||||
path: "agents/work-planner/org-1",
|
||||
token,
|
||||
});
|
||||
const first = await t.mutation(
|
||||
api.fluePersistence.acquireConversationProducer,
|
||||
{
|
||||
path: "agents/work-planner/org-1",
|
||||
producerId: "producer-1",
|
||||
token,
|
||||
}
|
||||
);
|
||||
await t.mutation(api.fluePersistence.acquireConversationProducer, {
|
||||
path: "agents/work-planner/org-1",
|
||||
producerId: "producer-2",
|
||||
token,
|
||||
});
|
||||
await expect(
|
||||
t.mutation(api.fluePersistence.appendConversationBatch, {
|
||||
incarnation: first.incarnation,
|
||||
path: "agents/work-planner/org-1",
|
||||
producerEpoch: first.producerEpoch,
|
||||
producerId: "producer-1",
|
||||
producerSequence: 0,
|
||||
recordsJson: '[{"id":"record-1","type":"message"}]',
|
||||
token,
|
||||
})
|
||||
).rejects.toThrow(/producer ownership is stale/u);
|
||||
|
||||
const attachment = {
|
||||
digest: "digest",
|
||||
id: "attachment-1",
|
||||
mimeType: "text/plain",
|
||||
size: 3,
|
||||
};
|
||||
const inserted = await t.mutation(api.fluePersistence.putAttachment, {
|
||||
attachment,
|
||||
bytes: new TextEncoder().encode("one").buffer,
|
||||
conversationId: "conversation-1",
|
||||
streamPath: "agents/work-planner/org-1",
|
||||
token,
|
||||
});
|
||||
const replay = await t.mutation(api.fluePersistence.putAttachment, {
|
||||
attachment,
|
||||
bytes: new TextEncoder().encode("one").buffer,
|
||||
conversationId: "conversation-1",
|
||||
streamPath: "agents/work-planner/org-1",
|
||||
token,
|
||||
});
|
||||
const conflict = await t.mutation(api.fluePersistence.putAttachment, {
|
||||
attachment,
|
||||
bytes: new TextEncoder().encode("two").buffer,
|
||||
conversationId: "conversation-1",
|
||||
streamPath: "agents/work-planner/org-1",
|
||||
token,
|
||||
});
|
||||
expect([inserted, replay, conflict]).toEqual([
|
||||
"inserted",
|
||||
"existing",
|
||||
"conflict",
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
143
packages/backend/convex/gitConnectionData.ts
Normal file
143
packages/backend/convex/gitConnectionData.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||
|
||||
// Provider ("forge") that owns a repository host. A project's source must be
|
||||
// served by the same forge whose credentials are attached, so a Gitea token is
|
||||
// never offered to GitHub and vice versa. Unknown hosts return null, leaving
|
||||
// the caller free to attach without a forge constraint.
|
||||
export const forgeForHost = (host: string): "github" | "gitea" | null => {
|
||||
const normalized = host.toLowerCase();
|
||||
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
|
||||
return "github";
|
||||
}
|
||||
if (normalized === "git.openputer.com") {
|
||||
return "gitea";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const persist = internalMutation({
|
||||
args: {
|
||||
credentialCiphertext: v.string(),
|
||||
credentialIv: v.string(),
|
||||
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
|
||||
provider: v.union(v.literal("github"), v.literal("gitea")),
|
||||
serverUrl: v.string(),
|
||||
userId: v.string(),
|
||||
username: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", args.userId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new ConvexError("Organization not found");
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("gitConnections")
|
||||
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) =>
|
||||
q
|
||||
.eq("organizationId", organization._id)
|
||||
.eq("provider", args.provider)
|
||||
.eq("serverUrl", args.serverUrl)
|
||||
)
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
credentialCiphertext: args.credentialCiphertext,
|
||||
credentialIv: args.credentialIv,
|
||||
credentialKind: args.credentialKind,
|
||||
updatedAt: timestamp,
|
||||
username: args.username,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("gitConnections", {
|
||||
connectedAt: timestamp,
|
||||
credentialCiphertext: args.credentialCiphertext,
|
||||
credentialIv: args.credentialIv,
|
||||
credentialKind: args.credentialKind,
|
||||
organizationId: organization._id,
|
||||
provider: args.provider,
|
||||
serverUrl: args.serverUrl,
|
||||
updatedAt: timestamp,
|
||||
username: args.username,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { organizationId } = await requireCurrentOrganization(ctx);
|
||||
const connections = await ctx.db
|
||||
.query("gitConnections")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", organizationId)
|
||||
)
|
||||
.collect();
|
||||
return connections.map((connection) => ({
|
||||
connectedAt: connection.connectedAt,
|
||||
credentialKind: connection.credentialKind,
|
||||
id: String(connection._id),
|
||||
provider: connection.provider,
|
||||
serverUrl: connection.serverUrl,
|
||||
username: connection.username,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const getForProject = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
const connection = project?.gitConnectionId
|
||||
? await ctx.db.get(project.gitConnectionId)
|
||||
: null;
|
||||
return connection
|
||||
? {
|
||||
connectedAt: connection.connectedAt,
|
||||
credentialKind: connection.credentialKind,
|
||||
id: String(connection._id),
|
||||
provider: connection.provider,
|
||||
serverUrl: connection.serverUrl,
|
||||
username: connection.username,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
export const attachToProject = mutation({
|
||||
args: {
|
||||
connectionId: v.id("gitConnections"),
|
||||
projectId: v.id("projects"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { organizationId } = await requireProjectMember(ctx, args.projectId);
|
||||
const connection = await ctx.db.get(args.connectionId);
|
||||
if (!connection || connection.organizationId !== organizationId) {
|
||||
throw new ConvexError("Git connection not found");
|
||||
}
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (project) {
|
||||
const expected = forgeForHost(project.sourceHost);
|
||||
if (expected && connection.provider !== expected) {
|
||||
throw new ConvexError(
|
||||
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})`
|
||||
);
|
||||
}
|
||||
}
|
||||
await ctx.db.patch(args.projectId, {
|
||||
gitConnectionId: connection._id,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { attached: true };
|
||||
},
|
||||
});
|
||||
124
packages/backend/convex/gitConnections.ts
Normal file
124
packages/backend/convex/gitConnections.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
"use node";
|
||||
|
||||
import { env } from "@code/env/convex";
|
||||
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action } from "./_generated/server";
|
||||
import { authComponent, createAuth } from "./auth";
|
||||
|
||||
const encryptionKey = async (): Promise<CryptoKey> => {
|
||||
if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) {
|
||||
throw new ConvexError("Git credential encryption is not configured");
|
||||
}
|
||||
const bytes = Buffer.from(env.GIT_CREDENTIAL_ENCRYPTION_KEY, "base64url");
|
||||
if (bytes.byteLength !== 32) {
|
||||
throw new ConvexError("Git credential encryption key must be 32 bytes");
|
||||
}
|
||||
return await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
};
|
||||
|
||||
const encryptCredential = async (credential: string) => {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ iv, name: "AES-GCM" },
|
||||
await encryptionKey(),
|
||||
new TextEncoder().encode(credential)
|
||||
);
|
||||
return {
|
||||
credentialCiphertext: Buffer.from(encrypted).toString("base64url"),
|
||||
credentialIv: Buffer.from(iv).toString("base64url"),
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptCredential = async (
|
||||
credentialCiphertext: string,
|
||||
credentialIv: string
|
||||
): Promise<string> => {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
iv: Buffer.from(credentialIv, "base64url"),
|
||||
name: "AES-GCM",
|
||||
},
|
||||
await encryptionKey(),
|
||||
Buffer.from(credentialCiphertext, "base64url")
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
};
|
||||
|
||||
export const connectGitea = action({
|
||||
args: {
|
||||
serverUrl: v.string(),
|
||||
token: v.string(),
|
||||
username: v.optional(v.string()),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{ connectionId: Id<"gitConnections"> }> => {
|
||||
const userId = await ctx.auth.getUserIdentity().then((identity) => {
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
}
|
||||
return identity.tokenIdentifier;
|
||||
});
|
||||
const connection = await Effect.runPromise(
|
||||
decodeGitConnectionInput({
|
||||
credential: args.token,
|
||||
credentialKind: "token",
|
||||
provider: "gitea",
|
||||
serverUrl: args.serverUrl,
|
||||
username: args.username,
|
||||
})
|
||||
);
|
||||
const encrypted = await encryptCredential(connection.credential);
|
||||
const connectionId = await ctx.runMutation(
|
||||
internal.gitConnectionData.persist,
|
||||
{
|
||||
...encrypted,
|
||||
credentialKind: connection.credentialKind,
|
||||
provider: connection.provider,
|
||||
serverUrl: connection.serverUrl,
|
||||
userId,
|
||||
username: connection.username,
|
||||
}
|
||||
);
|
||||
return { connectionId };
|
||||
},
|
||||
});
|
||||
|
||||
export const connectGithub = action({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ connectionId: Id<"gitConnections"> }> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
}
|
||||
const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
|
||||
const token = await auth.api.getAccessToken({
|
||||
body: { providerId: "github" },
|
||||
headers,
|
||||
});
|
||||
if (!token.accessToken) {
|
||||
throw new ConvexError("GitHub account is not connected");
|
||||
}
|
||||
const encrypted = await encryptCredential(token.accessToken);
|
||||
const connectionId = await ctx.runMutation(
|
||||
internal.gitConnectionData.persist,
|
||||
{
|
||||
...encrypted,
|
||||
credentialKind: "oauth",
|
||||
provider: "github",
|
||||
serverUrl: "https://github.com",
|
||||
userId: identity.tokenIdentifier,
|
||||
}
|
||||
);
|
||||
return { connectionId };
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { query } from "./_generated/server";
|
||||
|
||||
export const get = query({
|
||||
handler: async () => {
|
||||
return "OK";
|
||||
},
|
||||
handler: () =>
|
||||
"OK"
|
||||
,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ const ID_B = "https://convex.test|user-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
const newTest = () => convexTest({ modules, schema });
|
||||
|
||||
describe("organizations", () => {
|
||||
test("first ensure creates one organization and owner membership", async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
decodePublicGitImportResult,
|
||||
preparePublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
} from "@code/primitives/project";
|
||||
import type {
|
||||
ProjectImportOutcome,
|
||||
ProjectView,
|
||||
} from "@code/primitives/project";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
@@ -54,18 +56,11 @@ const toProjectView = async (
|
||||
|
||||
export const persistPublicGitImport = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
source: v.object({
|
||||
host: v.string(),
|
||||
projectName: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
remote: v.object({
|
||||
defaultBranch: v.optional(v.string()),
|
||||
documents: v.array(
|
||||
v.object({
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
@@ -75,11 +70,18 @@ export const persistPublicGitImport = internalMutation({
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(v.object({ path: v.string(), message: v.string() })),
|
||||
warnings: v.array(v.object({ message: v.string(), path: v.string() })),
|
||||
}),
|
||||
source: v.object({
|
||||
host: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
projectName: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
userId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const organization = await ctx.db
|
||||
@@ -106,9 +108,9 @@ export const persistPublicGitImport = internalMutation({
|
||||
await ctx.db.patch(projectId, {
|
||||
defaultBranch: args.remote.defaultBranch,
|
||||
name: args.source.projectName,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
sourceHost: args.source.host,
|
||||
sourceUrl: args.source.url,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const oldDocuments = await ctx.db
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
CONTEXT_KINDS,
|
||||
contextPathForKind,
|
||||
MAX_CONTEXT_DOCUMENT_CHARACTERS,
|
||||
type PreparedPublicGitSource,
|
||||
type PublicGitImportResult,
|
||||
type RepositoryContextDocument,
|
||||
} from "@code/primitives/project";
|
||||
import { CONTEXT_KINDS, contextPathForKind, MAX_CONTEXT_DOCUMENT_CHARACTERS } from '@code/primitives/project';
|
||||
import type { PreparedPublicGitSource, PublicGitImportResult, RepositoryContextDocument } from '@code/primitives/project';
|
||||
|
||||
const GITHUB_HOST = "github.com";
|
||||
const GITEA_HOST = "git.openputer.com";
|
||||
|
||||
@@ -1,129 +1,19 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
organizations: defineTable({
|
||||
name: v.string(),
|
||||
kind: v.union(v.literal("personal"), v.literal("team")),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_createdBy_and_kind", ["createdBy", "kind"]),
|
||||
organizationMembers: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
userId: v.string(),
|
||||
role: v.union(v.literal("owner"), v.literal("member")),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
projects: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
sourceUrl: v.string(),
|
||||
normalizedSourceUrl: v.string(),
|
||||
sourceHost: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_organizationId_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organizationId_and_normalizedSourceUrl", [
|
||||
"organizationId",
|
||||
"normalizedSourceUrl",
|
||||
]),
|
||||
projectContextDocuments: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
origin: v.literal("repository"),
|
||||
sourceUrl: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_projectId_and_path", ["projectId", "path"]),
|
||||
conversations: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
createdAt: v.number(),
|
||||
}).index("by_organizationId", ["organizationId"]),
|
||||
conversationTurns: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
clientRequestId: v.string(),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("processing"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
submissionId: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
}).index("by_conversationId_and_clientRequestId", [
|
||||
"conversationId",
|
||||
"clientRequestId",
|
||||
]),
|
||||
conversationMessages: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
turnId: v.id("conversationTurns"),
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
ordinal: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_conversationId_and_ordinal", ["conversationId", "ordinal"])
|
||||
.index("by_turnId_and_role", ["turnId", "role"]),
|
||||
conversationAttachments: defineTable({
|
||||
messageId: v.id("conversationMessages"),
|
||||
storageId: v.id("_storage"),
|
||||
filename: v.optional(v.string()),
|
||||
mimeType: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_messageId", ["messageId"]),
|
||||
signals: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
conversationId: v.id("conversations"),
|
||||
sourceKey: v.string(),
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
processedByAgentName: v.string(),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organization_and_sourceKey", ["organizationId", "sourceKey"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
signalConstraints: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
ordinal: v.number(),
|
||||
value: v.string(),
|
||||
}).index("by_signalId_and_ordinal", ["signalId", "ordinal"]),
|
||||
signalSources: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
messageId: v.id("conversationMessages"),
|
||||
ordinal: v.number(),
|
||||
rawTextSnapshot: v.string(),
|
||||
sourceCreatedAt: v.number(),
|
||||
})
|
||||
.index("by_signalId_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_messageId", ["messageId"]),
|
||||
works: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
objective: v.string(),
|
||||
status: v.union(
|
||||
/* eslint-disable sort-keys */
|
||||
|
||||
const attemptClassification = v.union(
|
||||
v.literal("Succeeded"),
|
||||
v.literal("RetryableFailure"),
|
||||
v.literal("NeedsInput"),
|
||||
v.literal("Blocked"),
|
||||
v.literal("VerificationFailed"),
|
||||
v.literal("BudgetExhausted"),
|
||||
v.literal("Cancelled"),
|
||||
v.literal("PermanentFailure")
|
||||
);
|
||||
const workStatus = v.union(
|
||||
v.literal("proposed"),
|
||||
v.literal("defining"),
|
||||
v.literal("awaiting-definition-approval"),
|
||||
@@ -136,29 +26,179 @@ export default defineSchema({
|
||||
v.literal("completed"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
definitionVersion: v.optional(v.number()),
|
||||
definitionApprovalVersion: v.optional(v.number()),
|
||||
designVersion: v.optional(v.number()),
|
||||
designApprovalVersion: v.optional(v.number()),
|
||||
);
|
||||
|
||||
export default defineSchema({
|
||||
organizations: defineTable({
|
||||
createdAt: v.number(),
|
||||
createdBy: v.string(),
|
||||
kind: v.union(v.literal("personal"), v.literal("team")),
|
||||
name: v.string(),
|
||||
}).index("by_createdBy_and_kind", ["createdBy", "kind"]),
|
||||
organizationMembers: defineTable({
|
||||
createdAt: v.number(),
|
||||
organizationId: v.id("organizations"),
|
||||
role: v.union(v.literal("owner"), v.literal("member")),
|
||||
userId: v.string(),
|
||||
})
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
gitConnections: defineTable({
|
||||
connectedAt: v.number(),
|
||||
credentialCiphertext: v.string(),
|
||||
credentialIv: v.string(),
|
||||
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
|
||||
organizationId: v.id("organizations"),
|
||||
provider: v.union(v.literal("github"), v.literal("gitea")),
|
||||
serverUrl: v.string(),
|
||||
updatedAt: v.number(),
|
||||
username: v.optional(v.string()),
|
||||
})
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_provider_and_serverUrl", [
|
||||
"organizationId",
|
||||
"provider",
|
||||
"serverUrl",
|
||||
]),
|
||||
projects: defineTable({
|
||||
createdAt: v.number(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
gitConnectionId: v.optional(v.id("gitConnections")),
|
||||
name: v.string(),
|
||||
normalizedSourceUrl: v.string(),
|
||||
organizationId: v.id("organizations"),
|
||||
repositoryPath: v.string(),
|
||||
sourceHost: v.string(),
|
||||
sourceUrl: v.string(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_organizationId_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organizationId_and_normalizedSourceUrl", [
|
||||
"organizationId",
|
||||
"normalizedSourceUrl",
|
||||
]),
|
||||
projectContextDocuments: defineTable({
|
||||
content: v.string(),
|
||||
createdAt: v.number(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
origin: v.literal("repository"),
|
||||
path: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
sourceUrl: v.string(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_projectId_and_path", ["projectId", "path"]),
|
||||
conversations: defineTable({
|
||||
createdAt: v.number(),
|
||||
organizationId: v.id("organizations"),
|
||||
}).index("by_organizationId", ["organizationId"]),
|
||||
conversationTurns: defineTable({
|
||||
attemptNumber: v.optional(v.number()),
|
||||
clientRequestId: v.string(),
|
||||
completedAt: v.optional(v.number()),
|
||||
conversationId: v.id("conversations"),
|
||||
createdAt: v.number(),
|
||||
error: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
leaseOwner: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("dispatching"),
|
||||
v.literal("running"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed"),
|
||||
v.literal("aborted")
|
||||
),
|
||||
submissionId: v.optional(v.string()),
|
||||
})
|
||||
.index("by_conversationId_and_clientRequestId", [
|
||||
"conversationId",
|
||||
"clientRequestId",
|
||||
])
|
||||
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"])
|
||||
.index("by_submissionId", ["submissionId"]),
|
||||
conversationMessages: defineTable({
|
||||
content: v.string(),
|
||||
conversationId: v.id("conversations"),
|
||||
createdAt: v.number(),
|
||||
ordinal: v.number(),
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
turnId: v.id("conversationTurns"),
|
||||
})
|
||||
.index("by_conversationId_and_ordinal", ["conversationId", "ordinal"])
|
||||
.index("by_turnId_and_role", ["turnId", "role"]),
|
||||
conversationAttachments: defineTable({
|
||||
createdAt: v.number(),
|
||||
filename: v.optional(v.string()),
|
||||
messageId: v.id("conversationMessages"),
|
||||
mimeType: v.string(),
|
||||
storageId: v.id("_storage"),
|
||||
}).index("by_messageId", ["messageId"]),
|
||||
signals: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
createdAt: v.number(),
|
||||
desiredOutcome: v.string(),
|
||||
organizationId: v.id("organizations"),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
processedByAgentName: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
sourceKey: v.string(),
|
||||
summary: v.string(),
|
||||
title: v.string(),
|
||||
})
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organization_and_sourceKey", ["organizationId", "sourceKey"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
signalConstraints: defineTable({
|
||||
ordinal: v.number(),
|
||||
signalId: v.id("signals"),
|
||||
value: v.string(),
|
||||
}).index("by_signalId_and_ordinal", ["signalId", "ordinal"]),
|
||||
signalSources: defineTable({
|
||||
messageId: v.id("conversationMessages"),
|
||||
ordinal: v.number(),
|
||||
rawTextSnapshot: v.string(),
|
||||
signalId: v.id("signals"),
|
||||
sourceCreatedAt: v.number(),
|
||||
})
|
||||
.index("by_signalId_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_messageId", ["messageId"]),
|
||||
works: defineTable({
|
||||
createdAt: v.number(),
|
||||
definitionApprovalVersion: v.optional(v.number()),
|
||||
definitionVersion: v.optional(v.number()),
|
||||
designApprovalVersion: v.optional(v.number()),
|
||||
designVersion: v.optional(v.number()),
|
||||
objective: v.string(),
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
status: workStatus,
|
||||
title: v.string(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
|
||||
signalWorkAttachments: defineTable({
|
||||
createdAt: v.number(),
|
||||
signalId: v.id("signals"),
|
||||
workId: v.id("works"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_signal", ["signalId"])
|
||||
.index("by_work", ["workId"])
|
||||
.index("by_signal_and_work", ["signalId", "workId"]),
|
||||
|
||||
workEvents: defineTable({
|
||||
workId: v.id("works"),
|
||||
signalId: v.optional(v.id("signals")),
|
||||
createdAt: v.number(),
|
||||
idempotencyKey: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("work.proposed"),
|
||||
v.literal("signal.attached"),
|
||||
@@ -167,6 +207,7 @@ export default defineSchema({
|
||||
v.literal("definition.revised"),
|
||||
v.literal("definition.approved"),
|
||||
v.literal("definition.invalidated"),
|
||||
v.literal("question.created"),
|
||||
v.literal("question.answered"),
|
||||
v.literal("question.withdrawn"),
|
||||
v.literal("design.requested"),
|
||||
@@ -174,24 +215,33 @@ export default defineSchema({
|
||||
v.literal("design.revised"),
|
||||
v.literal("design.approved"),
|
||||
v.literal("design.invalidated"),
|
||||
v.literal("planner.failed"),
|
||||
v.literal("slice.started"),
|
||||
v.literal("slice.completed"),
|
||||
v.literal("slice.ready"),
|
||||
v.literal("run.started"),
|
||||
v.literal("run.completed"),
|
||||
v.literal("run.cancelled"),
|
||||
v.literal("attempt.claimed"),
|
||||
v.literal("attempt.event"),
|
||||
v.literal("attempt.completed"),
|
||||
v.literal("attempt.reconciled")
|
||||
v.literal("attempt.reconciled"),
|
||||
v.literal("resolver.decided"),
|
||||
v.literal("artifact.recorded"),
|
||||
v.literal("delivery.recorded"),
|
||||
v.literal("delivery.updated")
|
||||
),
|
||||
referenceId: v.optional(v.string()),
|
||||
payloadJson: v.optional(v.string()),
|
||||
idempotencyKey: v.string(),
|
||||
createdAt: v.number(),
|
||||
referenceId: v.optional(v.string()),
|
||||
signalId: v.optional(v.id("signals")),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_work_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
workDefinitions: defineTable({
|
||||
workId: v.id("works"),
|
||||
version: v.number(),
|
||||
createdAt: v.number(),
|
||||
createdBy: v.string(),
|
||||
payloadJson: v.string(),
|
||||
risk: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
status: v.union(
|
||||
@@ -199,42 +249,55 @@ export default defineSchema({
|
||||
v.literal("current"),
|
||||
v.literal("superseded")
|
||||
),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
version: v.number(),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_work_and_version", ["workId", "version"])
|
||||
.index("by_work_and_status", ["workId", "status"]),
|
||||
|
||||
workQuestions: defineTable({
|
||||
workId: v.id("works"),
|
||||
definitionVersion: v.number(),
|
||||
questionId: v.string(),
|
||||
prompt: v.string(),
|
||||
impact: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
recommendation: v.optional(v.string()),
|
||||
alternativesJson: v.string(),
|
||||
answer: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
definitionVersion: v.number(),
|
||||
impact: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
prompt: v.string(),
|
||||
questionId: v.string(),
|
||||
recommendation: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("answered"),
|
||||
v.literal("withdrawn")
|
||||
),
|
||||
answer: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
}).index("by_work_and_definitionVersion", ["workId", "definitionVersion"]),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_workId_and_definitionVersion", ["workId", "definitionVersion"])
|
||||
.index("by_workId_and_definitionVersion_and_questionId", [
|
||||
"workId",
|
||||
"definitionVersion",
|
||||
"questionId",
|
||||
]),
|
||||
|
||||
workApprovals: defineTable({
|
||||
workId: v.id("works"),
|
||||
kind: v.union(v.literal("definition"), v.literal("design")),
|
||||
approvedAt: v.number(),
|
||||
approvedBy: v.string(),
|
||||
definitionVersion: v.number(),
|
||||
designVersion: v.optional(v.number()),
|
||||
approvedBy: v.string(),
|
||||
approvedAt: v.number(),
|
||||
kind: v.union(v.literal("definition"), v.literal("design")),
|
||||
status: v.union(v.literal("active"), v.literal("invalidated")),
|
||||
}).index("by_work_and_kind", ["workId", "kind"]),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_workId_and_kind", ["workId", "kind"])
|
||||
.index("by_workId_and_kind_and_definitionVersion_and_designVersion", [
|
||||
"workId",
|
||||
"kind",
|
||||
"definitionVersion",
|
||||
"designVersion",
|
||||
]),
|
||||
|
||||
designPackets: defineTable({
|
||||
workId: v.id("works"),
|
||||
version: v.number(),
|
||||
createdAt: v.number(),
|
||||
createdBy: v.string(),
|
||||
definitionVersion: v.number(),
|
||||
payloadJson: v.string(),
|
||||
status: v.union(
|
||||
@@ -242,19 +305,18 @@ export default defineSchema({
|
||||
v.literal("current"),
|
||||
v.literal("superseded")
|
||||
),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
version: v.number(),
|
||||
workId: v.id("works"),
|
||||
}).index("by_work_and_version", ["workId", "version"]),
|
||||
|
||||
workSlices: defineTable({
|
||||
workId: v.id("works"),
|
||||
createdAt: v.optional(v.number()),
|
||||
designVersion: v.number(),
|
||||
sliceId: v.string(),
|
||||
ordinal: v.number(),
|
||||
title: v.string(),
|
||||
objective: v.string(),
|
||||
observableBehavior: v.string(),
|
||||
ordinal: v.number(),
|
||||
payloadJson: v.string(),
|
||||
sliceId: v.string(),
|
||||
status: v.union(
|
||||
v.literal("planned"),
|
||||
v.literal("ready"),
|
||||
@@ -262,16 +324,27 @@ export default defineSchema({
|
||||
v.literal("completed"),
|
||||
v.literal("blocked")
|
||||
),
|
||||
}).index("by_work_and_designVersion", ["workId", "designVersion"]),
|
||||
title: v.string(),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_workId_and_designVersion", ["workId", "designVersion"])
|
||||
.index("by_workId_and_designVersion_and_sliceId", [
|
||||
"workId",
|
||||
"designVersion",
|
||||
"sliceId",
|
||||
]),
|
||||
|
||||
workRuns: defineTable({
|
||||
workId: v.id("works"),
|
||||
sliceId: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("running"),
|
||||
v.literal("terminal"),
|
||||
v.literal("cancelled")
|
||||
baseRevision: v.optional(v.string()),
|
||||
candidateRevision: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
designVersion: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
kitId: v.string(),
|
||||
kitVersion: v.string(),
|
||||
environmentId: v.optional(v.string()),
|
||||
executionKind: v.optional(
|
||||
v.union(v.literal("simulated"), v.literal("real"))
|
||||
),
|
||||
scenario: v.union(
|
||||
v.literal("success"),
|
||||
@@ -280,42 +353,153 @@ export default defineSchema({
|
||||
v.literal("permanent-failure"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
kitId: v.string(),
|
||||
kitVersion: v.string(),
|
||||
createdAt: v.number(),
|
||||
sliceId: v.optional(v.string()),
|
||||
sliceRowId: v.optional(v.id("workSlices")),
|
||||
startedAt: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
terminalClassification: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("running"),
|
||||
v.literal("terminal"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
terminalClassification: v.optional(attemptClassification),
|
||||
terminalSummary: v.optional(v.string()),
|
||||
workflowId: v.optional(v.string()),
|
||||
workId: v.id("works"),
|
||||
}).index("by_work_and_createdAt", ["workId", "createdAt"]),
|
||||
|
||||
workAttempts: defineTable({
|
||||
runId: v.id("workRuns"),
|
||||
workId: v.id("works"),
|
||||
classification: v.optional(attemptClassification),
|
||||
failureReason: v.optional(
|
||||
v.union(
|
||||
v.literal("Authentication"),
|
||||
v.literal("Cancelled"),
|
||||
v.literal("HarnessFailed"),
|
||||
v.literal("InvalidInput"),
|
||||
v.literal("ProviderUnavailable"),
|
||||
v.literal("RepositoryFailed"),
|
||||
v.literal("Timeout")
|
||||
)
|
||||
),
|
||||
endedAt: v.optional(v.number()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
leaseOwner: v.optional(v.string()),
|
||||
number: v.number(),
|
||||
runId: v.id("workRuns"),
|
||||
startedAt: v.optional(v.number()),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("terminal")
|
||||
),
|
||||
leaseOwner: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
startedAt: v.optional(v.number()),
|
||||
endedAt: v.optional(v.number()),
|
||||
classification: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
}).index("by_run_and_number", ["runId", "number"]),
|
||||
workId: v.id("works"),
|
||||
workspaceKey: v.optional(v.string()),
|
||||
})
|
||||
.index("by_runId_and_number", ["runId", "number"])
|
||||
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]),
|
||||
|
||||
workAttemptEvents: defineTable({
|
||||
attemptId: v.id("workAttempts"),
|
||||
sequence: v.number(),
|
||||
kind: v.string(),
|
||||
message: v.string(),
|
||||
metadataJson: v.string(),
|
||||
occurredAt: v.number(),
|
||||
sequence: v.number(),
|
||||
}).index("by_attempt_and_sequence", ["attemptId", "sequence"]),
|
||||
|
||||
resolverDecisions: defineTable({
|
||||
attemptId: v.id("workAttempts"),
|
||||
attemptNumber: v.number(),
|
||||
classification: attemptClassification,
|
||||
createdAt: v.number(),
|
||||
decision: v.union(v.literal("retry"), v.literal("terminal")),
|
||||
resultingWorkStatus: v.optional(workStatus),
|
||||
runId: v.id("workRuns"),
|
||||
summary: v.string(),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_runId_and_attemptNumber", ["runId", "attemptNumber"])
|
||||
.index("by_attemptId", ["attemptId"]),
|
||||
|
||||
workArtifacts: defineTable({
|
||||
attemptId: v.optional(v.id("workAttempts")),
|
||||
contentHash: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
designVersion: v.optional(v.number()),
|
||||
environmentId: v.optional(v.string()),
|
||||
idempotencyKey: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("definition"),
|
||||
v.literal("design"),
|
||||
v.literal("diff"),
|
||||
v.literal("test-report"),
|
||||
v.literal("verification-report"),
|
||||
v.literal("runtime-log"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("video"),
|
||||
v.literal("commit"),
|
||||
v.literal("branch"),
|
||||
v.literal("pull-request"),
|
||||
v.literal("preview"),
|
||||
v.literal("deployment"),
|
||||
v.literal("other")
|
||||
),
|
||||
metadataJson: v.string(),
|
||||
organizationId: v.id("organizations"),
|
||||
producer: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
provenanceJson: v.string(),
|
||||
runId: v.optional(v.id("workRuns")),
|
||||
sliceId: v.optional(v.string()),
|
||||
sourceRevision: v.optional(v.string()),
|
||||
title: v.string(),
|
||||
uri: v.optional(v.string()),
|
||||
verificationStatus: v.union(
|
||||
v.literal("unverified"),
|
||||
v.literal("verified"),
|
||||
v.literal("rejected")
|
||||
),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_workId_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_workId_and_idempotencyKey", ["workId", "idempotencyKey"])
|
||||
.index("by_runId_and_createdAt", ["runId", "createdAt"]),
|
||||
|
||||
workDeliveries: defineTable({
|
||||
artifactId: v.optional(v.id("workArtifacts")),
|
||||
createdAt: v.number(),
|
||||
externalId: v.string(),
|
||||
idempotencyKey: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("branch"),
|
||||
v.literal("commit"),
|
||||
v.literal("pull-request"),
|
||||
v.literal("preview"),
|
||||
v.literal("deployment")
|
||||
),
|
||||
metadataJson: v.string(),
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
provider: v.string(),
|
||||
sourceRevision: v.string(),
|
||||
status: v.union(
|
||||
v.literal("recorded"),
|
||||
v.literal("ready"),
|
||||
v.literal("approved"),
|
||||
v.literal("delivered"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
target: v.string(),
|
||||
updatedAt: v.number(),
|
||||
url: v.optional(v.string()),
|
||||
workId: v.id("works"),
|
||||
})
|
||||
.index("by_workId_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_workId_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Flue persistence stores (schema/format version 4).
|
||||
// -----------------------------------------------------------------
|
||||
@@ -382,38 +566,38 @@ export default defineSchema({
|
||||
// Durable evidence that a submission attempt started and has not yet
|
||||
// settled. Append-once by (submissionId, attemptId).
|
||||
flueAttemptMarkers: defineTable({
|
||||
submissionId: v.string(),
|
||||
attemptId: v.string(),
|
||||
createdAt: v.number(),
|
||||
submissionId: v.string(),
|
||||
})
|
||||
.index("by_submissionId_and_attemptId", ["submissionId", "attemptId"])
|
||||
.index("by_submissionId", ["submissionId"]),
|
||||
|
||||
// Conversation-stream metadata: one row per stream path.
|
||||
flueConversationStreams: defineTable({
|
||||
path: v.string(),
|
||||
identityJson: v.string(),
|
||||
incarnation: v.string(),
|
||||
producerId: v.optional(v.string()),
|
||||
producerEpoch: v.number(),
|
||||
nextProducerSequence: v.number(),
|
||||
nextOffset: v.number(),
|
||||
closed: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
identityJson: v.string(),
|
||||
incarnation: v.string(),
|
||||
nextOffset: v.number(),
|
||||
nextProducerSequence: v.number(),
|
||||
path: v.string(),
|
||||
producerEpoch: v.number(),
|
||||
producerId: v.optional(v.string()),
|
||||
}).index("by_path", ["path"]),
|
||||
|
||||
// Conversation-stream batches: one row per appended batch. Offset is
|
||||
// 0-based; `seq` is the row's position in the stream.
|
||||
flueConversationBatches: defineTable({
|
||||
appendedAt: v.number(),
|
||||
attemptId: v.optional(v.string()),
|
||||
path: v.string(),
|
||||
seq: v.number(),
|
||||
producerId: v.string(),
|
||||
producerEpoch: v.number(),
|
||||
producerId: v.string(),
|
||||
producerSequence: v.number(),
|
||||
recordsJson: v.string(),
|
||||
seq: v.number(),
|
||||
submissionId: v.optional(v.string()),
|
||||
attemptId: v.optional(v.string()),
|
||||
appendedAt: v.number(),
|
||||
})
|
||||
.index("by_path_and_seq", ["path", "seq"])
|
||||
.index("by_path_producer_epoch_producerSequence", [
|
||||
@@ -425,41 +609,41 @@ export default defineSchema({
|
||||
|
||||
// Event-stream metadata: one row per stream path.
|
||||
flueEventStreams: defineTable({
|
||||
path: v.string(),
|
||||
nextSeq: v.number(),
|
||||
closed: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
nextSeq: v.number(),
|
||||
path: v.string(),
|
||||
}).index("by_path", ["path"]),
|
||||
|
||||
// Event-stream entries: one row per appended event. `onceKey` carries the
|
||||
// idempotency key for `appendEventOnce` and is null for plain appends.
|
||||
flueEventEntries: defineTable({
|
||||
appendedAt: v.number(),
|
||||
dataJson: v.string(),
|
||||
onceKey: v.optional(v.string()),
|
||||
path: v.string(),
|
||||
seq: v.number(),
|
||||
onceKey: v.optional(v.string()),
|
||||
dataJson: v.string(),
|
||||
appendedAt: v.number(),
|
||||
})
|
||||
.index("by_path_and_seq", ["path", "seq"])
|
||||
.index("by_path_and_onceKey", ["path", "onceKey"]),
|
||||
|
||||
// Workflow run records.
|
||||
flueRuns: defineTable({
|
||||
durationMs: v.optional(v.number()),
|
||||
endedAt: v.optional(v.string()),
|
||||
errorJson: v.optional(v.string()),
|
||||
inputJson: v.optional(v.string()),
|
||||
isError: v.optional(v.boolean()),
|
||||
resultJson: v.optional(v.string()),
|
||||
runId: v.string(),
|
||||
workflowName: v.string(),
|
||||
startedAt: v.string(),
|
||||
status: v.union(
|
||||
v.literal("active"),
|
||||
v.literal("completed"),
|
||||
v.literal("errored")
|
||||
),
|
||||
startedAt: v.string(),
|
||||
inputJson: v.optional(v.string()),
|
||||
traceCarrierJson: v.optional(v.string()),
|
||||
endedAt: v.optional(v.string()),
|
||||
isError: v.optional(v.boolean()),
|
||||
durationMs: v.optional(v.number()),
|
||||
resultJson: v.optional(v.string()),
|
||||
errorJson: v.optional(v.string()),
|
||||
workflowName: v.string(),
|
||||
})
|
||||
.index("by_runId", ["runId"])
|
||||
.index("by_startedAt_and_runId", ["startedAt", "runId"])
|
||||
@@ -479,15 +663,15 @@ export default defineSchema({
|
||||
// Immutable attachment bytes. Identity is (streamPath, attachmentId); reads
|
||||
// are additionally scoped by conversationId.
|
||||
flueAttachments: defineTable({
|
||||
streamPath: v.string(),
|
||||
conversationId: v.string(),
|
||||
attachmentId: v.string(),
|
||||
mimeType: v.string(),
|
||||
size: v.number(),
|
||||
bytes: v.bytes(),
|
||||
conversationId: v.string(),
|
||||
createdAt: v.number(),
|
||||
digest: v.string(),
|
||||
filename: v.optional(v.string()),
|
||||
bytes: v.bytes(),
|
||||
createdAt: v.number(),
|
||||
mimeType: v.string(),
|
||||
size: v.number(),
|
||||
streamPath: v.string(),
|
||||
})
|
||||
.index("by_streamPath", ["streamPath"])
|
||||
.index("by_streamPath_and_attachmentId", ["streamPath", "attachmentId"])
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("signalRouting", () => {
|
||||
clientRequestId: "request-1",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
status: "processing",
|
||||
status: "running",
|
||||
});
|
||||
const messageId = await ctx.db.insert("conversationMessages", {
|
||||
content: "Fix the deploy",
|
||||
|
||||
@@ -60,7 +60,7 @@ export const listEvidence = query({
|
||||
continue;
|
||||
}
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
if (!turn || !["running", "completed"].includes(turn.status)) {
|
||||
continue;
|
||||
}
|
||||
const consumed = await ctx.db
|
||||
@@ -82,16 +82,16 @@ export const listEvidence = query({
|
||||
|
||||
export const createSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
messageIds: v.array(v.string()),
|
||||
organizationId: v.id("organizations"),
|
||||
problemStatement: v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
desiredOutcome: v.string(),
|
||||
summary: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -131,7 +131,7 @@ export const createSignal = mutation({
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
if (!turn || !["running", "completed"].includes(turn.status)) {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
messages.push(message);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"compilerOptions": {
|
||||
/* These settings are not required by Convex and can be modified. */
|
||||
"allowJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
108
packages/backend/convex/workArtifacts.test.ts
Normal file
108
packages/backend/convex/workArtifacts.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
describe("Work artifacts and delivery", () => {
|
||||
test("records exact idempotent artifact and delivery metadata", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const workId = await t.run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user",
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
return await ctx.db.insert("works", {
|
||||
createdAt: 1,
|
||||
objective: "Persist evidence",
|
||||
organizationId,
|
||||
projectId,
|
||||
status: "executing",
|
||||
title: "Artifact proof",
|
||||
updatedAt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const draft = {
|
||||
idempotencyKey: "attempt-1:test-report",
|
||||
kind: "test-report" as const,
|
||||
metadataJson: "{}",
|
||||
producer: "fake-harness",
|
||||
provenanceJson: "{}",
|
||||
sourceRevision: "abc123",
|
||||
title: "Focused tests",
|
||||
verificationStatus: "verified" as const,
|
||||
};
|
||||
const first = await t.mutation(api.workArtifacts.recordArtifact, {
|
||||
draft,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId,
|
||||
});
|
||||
const replay = await t.mutation(api.workArtifacts.recordArtifact, {
|
||||
draft,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId,
|
||||
});
|
||||
expect(first.created).toBe(true);
|
||||
expect(replay).toEqual({ artifactId: first.artifactId, created: false });
|
||||
|
||||
const delivery = await t.mutation(api.workArtifacts.recordDelivery, {
|
||||
artifactId: first.artifactId,
|
||||
draft: {
|
||||
externalId: "42",
|
||||
idempotencyKey: "pr:42",
|
||||
kind: "pull-request",
|
||||
metadataJson: "{}",
|
||||
provider: "gitea",
|
||||
sourceRevision: "abc123",
|
||||
status: "ready",
|
||||
target: "main",
|
||||
url: "https://git.example/pulls/42",
|
||||
},
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId,
|
||||
});
|
||||
expect(delivery.created).toBe(true);
|
||||
const updated = await t.mutation(api.workArtifacts.updateDeliveryStatus, {
|
||||
deliveryId: delivery.deliveryId,
|
||||
metadataJson: '{"approvedBy":"user"}',
|
||||
status: "approved",
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(updated).toEqual({ changed: true, status: "approved" });
|
||||
const stored = await t.run(async (ctx) => ({
|
||||
artifacts: await ctx.db.query("workArtifacts").collect(),
|
||||
deliveries: await ctx.db.query("workDeliveries").collect(),
|
||||
events: await ctx.db.query("workEvents").collect(),
|
||||
}));
|
||||
expect(stored.artifacts).toHaveLength(1);
|
||||
expect(stored.deliveries).toHaveLength(1);
|
||||
expect(stored.events.map((event) => event.kind)).toEqual([
|
||||
"artifact.recorded",
|
||||
"delivery.recorded",
|
||||
"delivery.updated",
|
||||
]);
|
||||
});
|
||||
});
|
||||
370
packages/backend/convex/workArtifacts.ts
Normal file
370
packages/backend/convex/workArtifacts.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
canTransitionWorkDelivery,
|
||||
decodeWorkArtifactDraft,
|
||||
decodeWorkDeliveryDraft,
|
||||
} from "@code/primitives/work-artifact";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const artifactKind = v.union(
|
||||
v.literal("definition"),
|
||||
v.literal("design"),
|
||||
v.literal("diff"),
|
||||
v.literal("test-report"),
|
||||
v.literal("verification-report"),
|
||||
v.literal("runtime-log"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("video"),
|
||||
v.literal("commit"),
|
||||
v.literal("branch"),
|
||||
v.literal("pull-request"),
|
||||
v.literal("preview"),
|
||||
v.literal("deployment"),
|
||||
v.literal("other")
|
||||
);
|
||||
const verificationStatus = v.union(
|
||||
v.literal("unverified"),
|
||||
v.literal("verified"),
|
||||
v.literal("rejected")
|
||||
);
|
||||
const deliveryKind = v.union(
|
||||
v.literal("branch"),
|
||||
v.literal("commit"),
|
||||
v.literal("pull-request"),
|
||||
v.literal("preview"),
|
||||
v.literal("deployment")
|
||||
);
|
||||
const deliveryStatus = v.union(
|
||||
v.literal("recorded"),
|
||||
v.literal("ready"),
|
||||
v.literal("approved"),
|
||||
v.literal("delivered"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
);
|
||||
|
||||
const requireAgent = (token: string): void => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const appendEvent = async (
|
||||
ctx: MutationCtx,
|
||||
workId: Id<"works">,
|
||||
kind: "artifact.recorded" | "delivery.recorded" | "delivery.updated",
|
||||
idempotencyKey: string,
|
||||
referenceId: string,
|
||||
payloadJson: string
|
||||
): Promise<void> => {
|
||||
const existing = await ctx.db
|
||||
.query("workEvents")
|
||||
.withIndex("by_work_and_idempotencyKey", (q) =>
|
||||
q.eq("workId", workId).eq("idempotencyKey", idempotencyKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt: Date.now(),
|
||||
idempotencyKey,
|
||||
kind,
|
||||
payloadJson,
|
||||
referenceId,
|
||||
workId,
|
||||
});
|
||||
};
|
||||
|
||||
export const recordArtifact = mutation({
|
||||
args: {
|
||||
attemptId: v.optional(v.id("workAttempts")),
|
||||
designVersion: v.optional(v.number()),
|
||||
draft: v.object({
|
||||
contentHash: v.optional(v.string()),
|
||||
environmentId: v.optional(v.string()),
|
||||
idempotencyKey: v.string(),
|
||||
kind: artifactKind,
|
||||
metadataJson: v.string(),
|
||||
producer: v.string(),
|
||||
provenanceJson: v.string(),
|
||||
sourceRevision: v.optional(v.string()),
|
||||
title: v.string(),
|
||||
uri: v.optional(v.string()),
|
||||
verificationStatus,
|
||||
}),
|
||||
runId: v.optional(v.id("workRuns")),
|
||||
sliceId: v.optional(v.string()),
|
||||
token: v.string(),
|
||||
workId: v.id("works"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const work = await ctx.db.get(args.workId);
|
||||
if (!work) {
|
||||
throw new ConvexError("Work not found");
|
||||
}
|
||||
const draft = await Effect.runPromise(
|
||||
decodeWorkArtifactDraft(args.draft)
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work artifact"
|
||||
);
|
||||
});
|
||||
const attempt = args.attemptId ? await ctx.db.get(args.attemptId) : null;
|
||||
if (args.attemptId && !attempt) {
|
||||
throw new ConvexError("Attempt not found");
|
||||
}
|
||||
const referencedRunId = args.runId ?? attempt?.runId;
|
||||
const run = referencedRunId ? await ctx.db.get(referencedRunId) : null;
|
||||
if (referencedRunId && !run) {
|
||||
throw new ConvexError("Run not found");
|
||||
}
|
||||
if (run && run.workId !== work._id) {
|
||||
throw new ConvexError("Run does not belong to Work");
|
||||
}
|
||||
if (
|
||||
attempt &&
|
||||
(attempt.workId !== work._id ||
|
||||
(run !== null && attempt.runId !== run._id))
|
||||
) {
|
||||
throw new ConvexError("Attempt does not belong to Work Run");
|
||||
}
|
||||
if (
|
||||
run &&
|
||||
((args.designVersion !== undefined &&
|
||||
args.designVersion !== run.designVersion) ||
|
||||
(args.sliceId !== undefined && args.sliceId !== run.sliceId))
|
||||
) {
|
||||
throw new ConvexError("Artifact provenance conflicts with Work Run");
|
||||
}
|
||||
const designVersion = args.designVersion ?? run?.designVersion;
|
||||
const sliceId = args.sliceId ?? run?.sliceId;
|
||||
if (designVersion !== undefined && sliceId !== undefined) {
|
||||
const slice = await ctx.db
|
||||
.query("workSlices")
|
||||
.withIndex("by_workId_and_designVersion_and_sliceId", (q) =>
|
||||
q
|
||||
.eq("workId", work._id)
|
||||
.eq("designVersion", designVersion)
|
||||
.eq("sliceId", sliceId)
|
||||
)
|
||||
.unique();
|
||||
if (!slice) {
|
||||
throw new ConvexError("Artifact slice not found");
|
||||
}
|
||||
} else if (designVersion !== undefined) {
|
||||
const design = await ctx.db
|
||||
.query("designPackets")
|
||||
.withIndex("by_work_and_version", (q) =>
|
||||
q.eq("workId", work._id).eq("version", designVersion)
|
||||
)
|
||||
.unique();
|
||||
if (!design) {
|
||||
throw new ConvexError("Artifact Design not found");
|
||||
}
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("workArtifacts")
|
||||
.withIndex("by_workId_and_idempotencyKey", (q) =>
|
||||
q.eq("workId", work._id).eq("idempotencyKey", draft.idempotencyKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
const exactReplay =
|
||||
existing.kind === draft.kind &&
|
||||
existing.title === draft.title &&
|
||||
existing.uri === draft.uri &&
|
||||
existing.contentHash === draft.contentHash &&
|
||||
existing.sourceRevision === draft.sourceRevision &&
|
||||
existing.environmentId === draft.environmentId &&
|
||||
existing.producer === draft.producer &&
|
||||
existing.provenanceJson === draft.provenanceJson &&
|
||||
existing.metadataJson === draft.metadataJson &&
|
||||
existing.verificationStatus === draft.verificationStatus &&
|
||||
existing.designVersion === designVersion &&
|
||||
existing.sliceId === sliceId &&
|
||||
existing.runId === run?._id &&
|
||||
existing.attemptId === attempt?._id;
|
||||
if (!exactReplay) {
|
||||
throw new ConvexError("Artifact idempotency key has conflicting data");
|
||||
}
|
||||
return { artifactId: existing._id, created: false };
|
||||
}
|
||||
const createdAt = Date.now();
|
||||
const artifactId = await ctx.db.insert("workArtifacts", {
|
||||
...draft,
|
||||
attemptId: attempt?._id,
|
||||
createdAt,
|
||||
designVersion,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
runId: run?._id,
|
||||
sliceId,
|
||||
workId: work._id,
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"artifact.recorded",
|
||||
`artifact:${draft.idempotencyKey}`,
|
||||
String(artifactId),
|
||||
JSON.stringify({ kind: draft.kind, title: draft.title })
|
||||
);
|
||||
return { artifactId, created: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const recordDelivery = mutation({
|
||||
args: {
|
||||
artifactId: v.optional(v.id("workArtifacts")),
|
||||
draft: v.object({
|
||||
externalId: v.string(),
|
||||
idempotencyKey: v.string(),
|
||||
kind: deliveryKind,
|
||||
metadataJson: v.string(),
|
||||
provider: v.string(),
|
||||
sourceRevision: v.string(),
|
||||
status: deliveryStatus,
|
||||
target: v.string(),
|
||||
url: v.optional(v.string()),
|
||||
}),
|
||||
token: v.string(),
|
||||
workId: v.id("works"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const work = await ctx.db.get(args.workId);
|
||||
if (!work) {
|
||||
throw new ConvexError("Work not found");
|
||||
}
|
||||
const draft = await Effect.runPromise(
|
||||
decodeWorkDeliveryDraft(args.draft)
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work delivery"
|
||||
);
|
||||
});
|
||||
const artifact = args.artifactId ? await ctx.db.get(args.artifactId) : null;
|
||||
if (args.artifactId && !artifact) {
|
||||
throw new ConvexError("Artifact not found");
|
||||
}
|
||||
if (artifact && artifact.workId !== work._id) {
|
||||
throw new ConvexError("Artifact does not belong to Work");
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("workDeliveries")
|
||||
.withIndex("by_workId_and_idempotencyKey", (q) =>
|
||||
q.eq("workId", work._id).eq("idempotencyKey", draft.idempotencyKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
const exactReplay =
|
||||
existing.kind === draft.kind &&
|
||||
existing.provider === draft.provider &&
|
||||
existing.externalId === draft.externalId &&
|
||||
existing.url === draft.url &&
|
||||
existing.sourceRevision === draft.sourceRevision &&
|
||||
existing.target === draft.target &&
|
||||
existing.status === draft.status &&
|
||||
existing.metadataJson === draft.metadataJson &&
|
||||
existing.artifactId === artifact?._id;
|
||||
if (!exactReplay) {
|
||||
throw new ConvexError("Delivery idempotency key has conflicting data");
|
||||
}
|
||||
return { created: false, deliveryId: existing._id };
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const deliveryId = await ctx.db.insert("workDeliveries", {
|
||||
...draft,
|
||||
artifactId: artifact?._id,
|
||||
createdAt: timestamp,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
updatedAt: timestamp,
|
||||
workId: work._id,
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
work._id,
|
||||
"delivery.recorded",
|
||||
`delivery:${draft.idempotencyKey}`,
|
||||
String(deliveryId),
|
||||
JSON.stringify({ kind: draft.kind, sourceRevision: draft.sourceRevision })
|
||||
);
|
||||
return { created: true, deliveryId };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateDeliveryStatus = mutation({
|
||||
args: {
|
||||
deliveryId: v.id("workDeliveries"),
|
||||
metadataJson: v.string(),
|
||||
status: deliveryStatus,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
// Validate JSON before persisting, matching recordDelivery's decode path.
|
||||
const metadata = JSON.parse(args.metadataJson) as unknown;
|
||||
if (typeof metadata !== "object" || metadata === null) {
|
||||
throw new ConvexError("Delivery metadata must be a JSON object");
|
||||
}
|
||||
const delivery = await ctx.db.get(args.deliveryId);
|
||||
if (!delivery) {
|
||||
throw new ConvexError("Delivery not found");
|
||||
}
|
||||
if (delivery.status === args.status) {
|
||||
return { changed: false, status: delivery.status };
|
||||
}
|
||||
if (!canTransitionWorkDelivery(delivery.status, args.status)) {
|
||||
throw new ConvexError(
|
||||
`Invalid delivery transition: ${delivery.status} -> ${args.status}`
|
||||
);
|
||||
}
|
||||
await ctx.db.patch(delivery._id, {
|
||||
metadataJson: args.metadataJson,
|
||||
status: args.status,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
await appendEvent(
|
||||
ctx,
|
||||
delivery.workId,
|
||||
"delivery.updated",
|
||||
`delivery-updated:${delivery._id}:${args.status}`,
|
||||
String(delivery._id),
|
||||
JSON.stringify({ from: delivery.status, to: args.status })
|
||||
);
|
||||
return { changed: true, status: args.status };
|
||||
},
|
||||
});
|
||||
|
||||
export const listForWork = query({
|
||||
args: { workId: v.id("works") },
|
||||
handler: async (ctx, args) => {
|
||||
const work = await ctx.db.get(args.workId);
|
||||
if (!work) {
|
||||
return null;
|
||||
}
|
||||
await requireProjectMember(ctx, work.projectId);
|
||||
const [artifacts, deliveries] = await Promise.all([
|
||||
ctx.db
|
||||
.query("workArtifacts")
|
||||
.withIndex("by_workId_and_createdAt", (q) => q.eq("workId", work._id))
|
||||
.order("desc")
|
||||
.take(100),
|
||||
ctx.db
|
||||
.query("workDeliveries")
|
||||
.withIndex("by_workId_and_createdAt", (q) => q.eq("workId", work._id))
|
||||
.order("desc")
|
||||
.take(50),
|
||||
]);
|
||||
return { artifacts, deliveries };
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user