30 Commits

Author SHA1 Message Date
-Puter
3ffa1cfc7c feat: project durable agent conversations 2026-07-29 08:19:17 +05:30
-Puter
18eb150d7d fix: serve web assets from app directory 2026-07-29 08:13:52 +05:30
-Puter
e1b0b731e0 deploy: attach backend to shared network 2026-07-29 08:09:30 +05:30
-Puter
d428a2492b fix: make primitives Node ESM compatible 2026-07-29 07:51:07 +05:30
-Puter
fe0fd9b16c deploy: enable Node TypeScript export resolution 2026-07-29 07:40:44 +05:30
-Puter
fc1fcf5d44 deploy: run agent backend with Bun 2026-07-29 07:31:36 +05:30
-Puter
3ae72864bd deploy: target agent package during Flue build 2026-07-29 07:21:02 +05:30
-Puter
0d5d54caa8 deploy: build agent without local env file 2026-07-29 07:15:19 +05:30
-Puter
830bcc4756 deploy: build applications on Node base 2026-07-29 07:09:07 +05:30
-Puter
0e56a462cd deploy: prefer Node for native install scripts 2026-07-29 07:03:32 +05:30
-Puter
9fb293a539 deploy: build native modules with Node 2026-07-29 07:00:47 +05:30
-Puter
062c00f53c deploy: install frontend build toolchain 2026-07-29 06:58:04 +05:30
-Puter
a7e70c9b2a deploy: install backend build toolchain 2026-07-29 06:56:36 +05:30
-Puter
526ed59776 deploy: fix compose build contexts 2026-07-29 06:46:07 +05:30
-Puter
fd3980c6bf deploy: split Dokploy services 2026-07-29 06:42:06 +05:30
-Puter
d8a4bbe804 chore cleanp 2026-07-29 01:24:37 +05:30
-Puter
9eb6bcd25f refactor canonical components and remove slice 1 2026-07-29 01:24:16 +05:30
-Puter
a907539810 flue refactor and migrations 2026-07-29 00:45:46 +05:30
-Puter
601aca73c2 pnpm migrations 2026-07-29 00:45:33 +05:30
-Puter
ffecff3857 Run fixed Zopu worktrees with Pi 2026-07-28 22:20:42 +05:30
-Puter
0d7162544b fix: run AgentOS workspaces on remote runner 2026-07-28 20:08:46 +05:30
-Puter
092a9793ea fix: resolve repository hosts outside AgentOS 2026-07-28 18:30:08 +05:30
-Puter
5ee0a8d50e fix: allow AgentOS process environment 2026-07-28 18:27:16 +05:30
-Puter
420676f2d7 fix: allow AgentOS command execution 2026-07-28 18:21:31 +05:30
-Puter
24d82e2a06 fix: allow AgentOS workspace filesystem 2026-07-28 18:18:24 +05:30
-Puter
d47fa0e96a fix: allow slow AgentOS workspace boot 2026-07-28 18:14:32 +05:30
-Puter
1e7c893985 fix: apply AgentOS runtime permissions 2026-07-28 17:49:41 +05:30
-Puter
f9ebcb4a01 fix: align AgentOS runtime packages 2026-07-28 17:40:40 +05:30
-Puter
dceaa2b417 fix: allow AgentOS outbound networking 2026-07-28 17:29:47 +05:30
-Puter
a4f121e190 chore: preserve work attempt schema order 2026-07-28 17:12:54 +05:30
76 changed files with 23788 additions and 6421 deletions

1
.npmrc Normal file
View File

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

36
apps/web/Dockerfile Normal file
View File

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

View File

@@ -1,887 +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 {
AlertTriangle,
ChevronRight,
Check,
FileCode2,
FolderGit2,
Hammer,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Play,
RotateCcw,
ScrollText,
Send,
Sparkles,
Settings,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
type SliceArtifact = NonNullable<
NonNullable<SliceWork["runs"][number]["artifacts"]>[number]
>;
interface ArtifactMetadata {
readonly changedFiles?: readonly string[];
}
const parseArtifactMetadata = (artifact: SliceArtifact): ArtifactMetadata => {
if (!artifact.metadataJson) {
return {};
}
try {
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
} catch {
return {};
}
};
const changedFilesFor = (artifact: SliceArtifact): readonly string[] =>
parseArtifactMetadata(artifact).changedFiles ?? [];
interface WorkCardProps {
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 [logsOpen, setLogsOpen] = 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>
{slice.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{slice.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => slice.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
{latestRun ? (
<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) => {
const changedFiles = changedFilesFor(artifact);
return (
<div key={artifact._id} className="space-y-1">
{artifact.uri ? (
<a
className="block font-medium underline"
href={artifact.uri}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
) : (
<p className="font-medium">{artifact.title}</p>
)}
{changedFiles.length > 0 ? (
<ul className="space-y-0.5">
{changedFiles.map((file) => (
<li
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
key={file}
>
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
<span className="min-w-0 break-all">{file}</span>
</li>
))}
</ul>
) : null}
</div>
);
})}
{latestRun.attemptEvents &&
latestRun.attemptEvents.length > 0 ? (
<div className="space-y-1">
{(logsOpen
? latestRun.attemptEvents
: latestRun.attemptEvents.slice(-3)
).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2 leading-5"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.attemptEvents.length > 3 ? (
<button
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
onClick={() => setLogsOpen((open) => !open)}
type="button"
>
<ScrollText className="size-3.5" />
{logsOpen
? "Show recent activity"
: `Show full activity log (${latestRun.attemptEvents.length})`}
<ChevronRight
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
/>
</button>
) : null}
</div>
) : null}
</div>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<Button
size="sm"
disabled={!slice.projectGitConnection}
onClick={() => void slice.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
) : null}
{work.status === "ready" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void slice.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"
? slice.cancelExecution(latestRun._id)
: slice.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 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>
);
// oxlint-disable-next-line complexity -- this page coordinates the existing mobile shell without owning domain logic.
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [giteaUrl, setGiteaUrl] = useState("https://git.openputer.com");
const [giteaUsername, setGiteaUsername] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return <ProjectsLoading />;
}
if (!slice.selectedProject) {
return <ConnectProject slice={slice} />;
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await slice.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<Button
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button
aria-label="Close settings"
onClick={() => setSettingsOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{slice.projectGitConnection
? `${slice.projectGitConnection.provider} · ${slice.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
{slice.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{slice.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => slice.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void slice.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button
size="sm"
onClick={() => void slice.connectLinkedGithub()}
>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUrl(event.target.value)}
value={giteaUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUsername(event.target.value)}
placeholder="Username (optional)"
value={giteaUsername}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={giteaToken}
/>
<Button
className="w-full"
disabled={!giteaToken.trim()}
size="sm"
onClick={() => {
void slice.connectGitea({
serverUrl: giteaUrl,
token: giteaToken,
username: giteaUsername || undefined,
});
setGiteaToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
) : null}
</header>
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{!slice.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{slice.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<WorkCard
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${
highlightedMessageId === item.message.id
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
: ""
}`}
id={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
))}
</div>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,12 @@ export const useOrganizationChatAgent = (
const projected = projectConversation(rows ?? []); 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) { if (!organizationId || rows === undefined) {
status = organization.error ? "error" : "connecting"; status = organization.error ? "error" : "connecting";
} else if (projected.failedError || sendError) { } else if (projected.failedError || sendError) {

View File

@@ -7,60 +7,17 @@ import { useMemo, useState } from "react";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent"; import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization"; import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) => const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
interface WorkRecord { type ExecutionScenario =
readonly _id: Id<"works">; | "success"
readonly title: string; | "transient-failure-then-success"
readonly objective: string; | "needs-input"
readonly status: string; | "permanent-failure"
readonly definitionVersion?: number; | "cancelled";
readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly designApprovalVersion?: number;
readonly signals: readonly {
sources: readonly { messageId: string; rawText: string }[];
}[];
readonly events: readonly { _id: string; createdAt: number; kind: string }[];
readonly definitions: readonly unknown[];
readonly designs: readonly unknown[];
readonly slices: readonly unknown[];
readonly runs: readonly {
readonly artifacts?: readonly {
_id: string;
kind: string;
metadataJson: string;
sourceRevision?: string;
title: string;
uri?: string;
}[];
readonly attemptEvents?: readonly {
_id: string;
kind: string;
message: string;
metadataJson: string;
occurredAt: number;
sequence: number;
}[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
_id: Id<"workRuns">;
status: string;
terminalClassification?: string;
terminalSummary?: string;
}[];
readonly definition: {
risk?: string;
questions?: { status: string }[];
} | null;
readonly design: {
architectureSummary?: string;
slices?: { id: string; title: string; observableBehavior: string }[];
} | null;
}
const workListRef = makeFunctionReference< const workListRef = makeFunctionReference<
"query", "query",
@@ -94,16 +51,7 @@ const approveDesignRef = makeFunctionReference<
>("workPlanning:approveDesign"); >("workPlanning:approveDesign");
const startSimulationRef = makeFunctionReference< const startSimulationRef = makeFunctionReference<
"mutation", "mutation",
{ { workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
workId: Id<"works">;
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
sliceId?: string;
},
unknown unknown
>("workExecution:startSimulatedExecution"); >("workExecution:startSimulatedExecution");
const cancelSimulationRef = makeFunctionReference< const cancelSimulationRef = makeFunctionReference<
@@ -162,13 +110,7 @@ const attachGitConnectionRef = makeFunctionReference<
unknown unknown
>("gitConnectionData:attachToProject"); >("gitConnectionData:attachToProject");
const authorizeGithub = () => export const useProjectWorkspace = (): WorkspaceState => {
authClient.signIn.social({
callbackURL: window.location.href,
provider: "github",
});
export const useSliceOne = () => {
const organization = usePersonalOrganization(); const organization = usePersonalOrganization();
const projects = useQuery( const projects = useQuery(
api.projects.list, api.projects.list,
@@ -182,19 +124,7 @@ export const useSliceOne = () => {
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>(); const [operationError, setOperationError] = useState<Error>();
const captureOperationError =
<TArgs extends unknown[], TResult>(
operation: (...args: TArgs) => Promise<TResult>
) =>
async (...args: TArgs): Promise<TResult> => {
setOperationError(undefined);
try {
return await operation(...args);
} catch (caughtError) {
setOperationError(toError(caughtError));
throw caughtError;
}
};
const selectedProjectStillExists = projects?.some( const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string) (project) => project.id === (selectedProjectId as unknown as string)
); );
@@ -234,6 +164,16 @@ export const useSliceOne = () => {
[activeProjectId, projects] [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 connectRepository = async () => {
const repositoryUrl = repository.trim(); const repositoryUrl = repository.trim();
if (!repositoryUrl || pending) { if (!repositoryUrl || pending) {
@@ -252,44 +192,6 @@ export const useSliceOne = () => {
} }
}; };
const selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
const requestDefinition = (workId: Id<"works">) =>
requestDefinitionMutation({ workId });
const saveDefinition = (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId });
const approveDefinition = (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId });
const saveDesign = (workId: Id<"works">, payload: unknown) =>
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId });
const approveDesign = (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId });
const startSimulation = (
workId: Id<"works">,
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled",
sliceId?: string
) =>
captureOperationError(() =>
startSimulationMutation({ scenario, sliceId, workId })
)();
const cancelSimulation = (runId: Id<"workRuns">) =>
captureOperationError(() => cancelSimulationMutation({ runId }))();
const retrySimulation = (runId: Id<"workRuns">) =>
captureOperationError(() => retrySimulationMutation({ runId }))();
const startExecution = (workId: Id<"works">, sliceId?: string) =>
captureOperationError(() => startExecutionMutation({ sliceId, workId }))();
const cancelExecution = (runId: Id<"workRuns">) =>
captureOperationError(() => cancelExecutionMutation({ runId }))();
const attachConnection = async (connectionId: Id<"gitConnections">) => { const attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) { if (!activeProjectId) {
throw new Error("Select a project first"); throw new Error("Select a project first");
@@ -299,24 +201,41 @@ export const useSliceOne = () => {
projectId: activeProjectId, projectId: activeProjectId,
}); });
}; };
const connectGitea = captureOperationError(
async (input: { serverUrl: string; token: string; username?: string }) => { const connectGitea = (input: {
serverUrl: string;
token: string;
username?: string;
}) =>
runOperation(async () => {
const result = await connectGiteaAction(input); const result = await connectGiteaAction(input);
await attachConnection(result.connectionId); await attachConnection(result.connectionId);
} });
);
const connectLinkedGithub = captureOperationError(async () => { const connectLinkedGithub = () =>
runOperation(async () => {
const result = await connectGithubAction({}); const result = await connectGithubAction({});
await attachConnection(result.connectionId); await attachConnection(result.connectionId);
}); });
return { return {
agent, agent,
approveDefinition, approveDefinition: (workId: Id<"works">, version: number) =>
approveDesign, approveDefinitionMutation({ version, workId }),
authorizeGithub, approveDesign: (
cancelExecution, workId: Id<"works">,
cancelSimulation, 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), clearOperationError: () => setOperationError(undefined),
connectGitea, connectGitea,
connectLinkedGithub, connectLinkedGithub,
@@ -328,15 +247,30 @@ export const useSliceOne = () => {
projectGitConnection, projectGitConnection,
projects, projects,
repository, repository,
requestDefinition, requestDefinition: (workId: Id<"works">) =>
retrySimulation, requestDefinitionMutation({ workId }),
saveDefinition, retrySimulation: (runId: Id<"workRuns">) =>
saveDesign, runOperation(() => retrySimulationMutation({ runId })),
selectProject, saveDefinition: (workId: Id<"works">, payload: unknown) =>
selectedProject, 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, setRepository,
startExecution, startExecution: (workId: Id<"works">, sliceId?: string) =>
startSimulation, runOperation(() => startExecutionMutation({ sliceId, workId })),
startSimulation: (
workId: Id<"works">,
scenario: ExecutionScenario,
sliceId?: string
) =>
runOperation(() =>
startSimulationMutation({ scenario, sliceId, workId })
),
works, works,
} as const; };
}; };

View File

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

View File

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

View File

@@ -6,8 +6,8 @@ body {
min-height: 100%; min-height: 100%;
} }
html.slice-one-viewport-lock, html.workspace-viewport-lock,
html.slice-one-viewport-lock body { html.workspace-viewport-lock body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
@@ -116,9 +116,9 @@ html.slice-one-viewport-lock body {
color: var(--foreground); color: var(--foreground);
} }
.slice-one-surface .chat-markdown, .workspace-surface .chat-markdown,
.slice-one-surface .chat-reasoning, .workspace-surface .chat-reasoning,
.slice-one-surface .thinking-line { .workspace-surface .thinking-line {
color: #232321; color: #232321;
} }

View File

@@ -20,7 +20,7 @@ describe("projectConversation", () => {
messageId: "user-1", messageId: "user-1",
rawText: "Build it", rawText: "Build it",
role: "user", role: "user",
status: "processing", status: "dispatching",
}), }),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }), row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]); ]);
@@ -28,6 +28,21 @@ describe("projectConversation", () => {
expect(state.messages).toHaveLength(1); 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", () => { test("projects completed Convex rows into renderable messages", () => {
const state = projectConversation([ const state = projectConversation([
row({ row({

View File

@@ -11,15 +11,35 @@ export interface ConversationRow {
readonly messageId: string; readonly messageId: string;
readonly rawText: string; readonly rawText: string;
readonly role: "assistant" | "user"; 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( failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed" (row) => row.role === "assistant" && row.status === "failed"
)?.error, )?.error,
messages: rows 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) => ({ .map<ConversationMessage>((row) => ({
id: row.messageId, id: row.messageId,
parts: [ parts: [
@@ -33,7 +53,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
...(row.rawText ...(row.rawText
? [ ? [
{ {
state: "done" as const, state:
row.status === "running"
? ("streaming" as const)
: ("done" as const),
text: row.rawText, text: row.rawText,
type: "text" as const, type: "text" as const,
}, },
@@ -45,6 +68,10 @@ export const projectConversation = (rows: readonly ConversationRow[]) => ({
pending: rows.some( pending: rows.some(
(row) => (row) =>
row.role === "assistant" && row.role === "assistant" &&
(row.status === "queued" || row.status === "processing") (row.status === "queued" ||
row.status === "dispatching" ||
row.status === "running")
), ),
}); streaming,
};
};

View File

@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
const source = (relativePath: string) => const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8"); 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", () => { 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 root = source("../../root.tsx");
const styles = source("../../index.css"); const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content"); expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}"); expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0"); expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh"); expect(viewport).toContain("workspace-viewport-lock");
expect(styles).toContain("html.slice-one-viewport-lock body"); expect(styles).toContain("html.workspace-viewport-lock body");
expect(styles).toContain("overflow: hidden"); expect(styles).toContain("overflow: hidden");
}); });
test("keeps the responsive shell shrinkable with a pinned composer", () => { 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('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">'); expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t'); expect(composer).toContain('className="shrink-0 border-t');
expect(page).toContain( expect(composer).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"' 'className="mx-auto flex max-w-2xl items-end gap-2"'
); );
}); });

View File

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

View File

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

View 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 ?? [];

View File

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

View File

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

View File

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

View File

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

4647
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
FROM oven/bun:1.3.14 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun run --filter @code/agents build
FROM node:24-bookworm-slim
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "packages/agents/dist/server.mjs"]

View File

@@ -0,0 +1,31 @@
services:
backend:
build:
context: ../..
dockerfile: packages/agents/Dockerfile
environment:
AGENT_BACKEND_URL: ${AGENT_BACKEND_URL}
AGENT_MODEL_API: ${AGENT_MODEL_API}
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
CONVEX_URL: ${CONVEX_URL}
DAEMON_ID: zopu-agent-backend
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
GITEA_TOKEN: ${GITEA_TOKEN:-}
GITEA_URL: ${GITEA_URL:-https://git.openputer.com}
PORT: "3000"
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
networks:
- default
- dokploy-network
restart: unless-stopped
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,15 @@
services:
frontend:
build:
context: ../..
dockerfile: apps/web/Dockerfile
args:
VITE_AUTH_URL: ${VITE_AUTH_URL}
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
environment:
HOST: 0.0.0.0
NODE_ENV: production
PORT: "3000"
VITE_AUTH_URL: ${VITE_AUTH_URL}
VITE_CONVEX_URL: ${VITE_CONVEX_URL}
restart: unless-stopped

View File

@@ -0,0 +1,23 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/zopu-source
COPY . .
RUN bun install --frozen-lockfile
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
ENV BUN_EXECUTABLE=/usr/local/bin/bun
ENV DAEMON_ID=zopu-agentos-runner
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
VOLUME ["/var/lib/zopu/workspaces"]
CMD ["bun", "packages/agents/src/runner.ts"]

View File

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

View File

@@ -0,0 +1,21 @@
FROM oven/bun:1.3.14 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun run --filter web build
FROM node:24-bookworm-slim
ENV HOST=0.0.0.0
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "apps/web/node_modules/.bin/react-router-serve", "apps/web/build/server/index.js"]

View File

@@ -25,14 +25,7 @@ CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET= CONVEX_INSTANCE_SECRET=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle # 2. Model gateway — REQUIRED
# The agent daemon clones repos and creates PRs through Gitea.
# ---------------------------------------------------------------------------
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-gitea-api-token
# ---------------------------------------------------------------------------
# 3. Model gateway — REQUIRED
# All model calls route through this OpenAI-compatible endpoint. # All model calls route through this OpenAI-compatible endpoint.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
AGENT_MODEL_PROVIDER=cheaptricks AGENT_MODEL_PROVIDER=cheaptricks
@@ -44,17 +37,17 @@ AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072 AGENT_MODEL_MAX_TOKENS=131072
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. AgentOS / Rivet Engine — REQUIRED for the execution runner # 3. AgentOS / Rivet Engine — REQUIRED for the execution runner
# The agent service and runner connect through the public engine endpoint. # The runner creates isolated worktrees from ZOPU_SOURCE_REPOSITORY and
# RIVET_WORKSPACE_TOKEN authenticates every workspace actor connection. # connects to AgentOS through the public engine endpoint.
# RIVET_ENVOY_VERSION must change for each runner deployment.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
RIVET_ENDPOINT=https://default:@rivet.example.com RIVET_ENDPOINT=https://default:@rivet.example.com
RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com
RIVET_ENVOY_VERSION=1 RIVET_RUNNER_VERSION=1
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue) # 4. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter. # FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# zopu-agent.service pins the Flue Node server to port 3583. # zopu-agent.service pins the Flue Node server to port 3583.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -62,7 +55,7 @@ FLUE_DB_TOKEN=replace-with-long-random-token
AGENT_BACKEND_URL=https://zopu-agent.example.com AGENT_BACKEND_URL=https://zopu-agent.example.com
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Daemon identity # 5. Daemon identity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu-Dedicated-Server DAEMON_NAME=Zopu-Dedicated-Server

View File

@@ -5,12 +5,19 @@ FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \ && apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /opt/zopu-source
COPY . . COPY . .
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
ENV AGENT_WORKSPACE_ROOT=/var/lib/zopu/workspaces
ENV BUN_EXECUTABLE=/usr/local/bin/bun
ENV DAEMON_ID=zopu-agentos-runner
ENV ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
VOLUME ["/var/lib/zopu/workspaces"]
CMD ["bun", "packages/agents/src/runner.ts"] CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -16,7 +16,11 @@ services:
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN} FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
RIVET_ENDPOINT: ${RIVET_ENDPOINT} RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT} RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_ENVOY_VERSION: ${RIVET_ENVOY_VERSION}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN} RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
RIVET_POOL: default ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
volumes:
- zopu-agentos-workspaces:/var/lib/zopu/workspaces
restart: unless-stopped restart: unless-stopped
volumes:
zopu-agentos-workspaces:

View File

@@ -1,59 +1,12 @@
{ {
"name": "code", "name": "code",
"private": true, "private": true,
"workspaces": {
"packages": [
"apps/web",
"packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "^0.2.7",
"@rivet-dev/agentos-core": "^0.2.10",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "^17.4.2",
"zod": "^4.4.3",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "^2.0.7",
"convex": "^1.42.1",
"better-auth": "1.6.15",
"@convex-dev/better-auth": "^0.12.5",
"@tanstack/react-form": "^1.33.0",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.3.2",
"tailwind-merge": "^3.6.0",
"@better-auth/expo": "1.6.15",
"effect": "4.0.0-beta.99",
"typescript": "^6",
"@types/bun": "latest",
"heroui-native": "^1.0.5",
"vite": "^7.3.6",
"vitest": "^4.1.10",
"convex-test": "^0.0.54",
"react-native": "0.86.0",
"@types/react": "^19.2.17",
"@types/node": "^22.13.14",
"hono": "^4.8.3",
"valibot": "^1.4.2",
"streamdown": "2.5.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2"
}
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vp run -r dev", "dev": "vp run -r dev",
"build": "vp run -r build", "build": "vp run -r build",
"check-types": "vp run -r check-types", "check-types": "vp run -r check-types",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/slice-one apps/web/src/hooks/chat apps/web/src/hooks/slice-one apps/web/src/lib/chat apps/web/src/lib/slice-one apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts packages/backend/convex/schema.ts packages/backend/convex/signalRouting.ts packages/backend/convex/signalRouting.test.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/backend/convex/workArtifacts.ts packages/backend/convex/workArtifacts.test.ts packages/backend/convex/workExecution.ts packages/backend/convex/workExecution.test.ts packages/backend/convex/workPlanning.ts packages/backend/convex/workPlanning.test.ts packages/backend/convex/crons.ts packages/primitives/src/work.ts packages/primitives/src/work.test.ts packages/primitives/src/work-artifact.ts packages/primitives/src/work-artifact.test.ts packages/primitives/src/resolver.ts packages/primitives/src/work-lifecycle.ts packages/primitives/src/work-resolution.test.ts", "check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
"lint": "oxlint --disable-nested-config", "lint": "oxlint --disable-nested-config",
"format": "vp fmt", "format": "vp fmt",
"staged": "vp staged", "staged": "vp staged",
@@ -65,14 +18,10 @@
"dev:server": "vp run --filter @code/backend dev", "dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup", "dev:setup": "vp run --filter @code/backend dev:setup",
"build:agents": "vp run --filter @code/agents build", "build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts", "docs:update": "node scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts", "subtree": "node scripts/subtree.ts",
"fix": "ultracite fix", "fix": "ultracite fix"
"slice1": "vp run -r dev",
"dev:zopu": "vp run --filter @code/agents dev",
"dev:zopu:web": "vp run --filter web dev"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/config": "workspace:*", "@code/config": "workspace:*",
@@ -84,18 +33,13 @@
"convex": "catalog:", "convex": "catalog:",
"convex-test": "catalog:", "convex-test": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"oxfmt": "latest", "oxfmt": "0.61.0",
"oxlint": "latest", "oxlint": "1.76.0",
"rolldown": "1.1.4", "rolldown": "1.1.4",
"typescript": "catalog:", "typescript": "catalog:",
"ultracite": "7.9.3", "ultracite": "7.9.3",
"vite-plus": "0.2.2", "vite-plus": "0.2.2",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"overrides": { "packageManager": "pnpm@11.17.0"
"react": "19.2.8",
"react-dom": "19.2.8",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "bun@1.3.14"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,7 @@ app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
}); });
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw)); app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
app.route("/", flue()); app.route("/", flue());
export default app satisfies Fetchable; export default app satisfies Fetchable;

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,38 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { agentOS, setup } from "@rivet-dev/agentos";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
import {
codexSessionEnv,
makeCodexAgentOsConfig,
} from "../../../primitives/src/agent-os";
import { import {
makePiAgentOsConfig,
makePiHomeFiles,
decodeWorkAttemptExecutionInput, decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError, WorkAttemptExecutionError,
} from "../../../primitives/src/execution-runtime"; piSessionEnv,
} from "@code/primitives";
import type { import type {
ExecutionEvent, ExecutionEvent,
WorkAttemptExecutionResult, WorkAttemptExecutionResult,
} from "../../../primitives/src/execution-runtime"; } from "@code/primitives";
import { agentOS, setup } from "@rivet-dev/agentos";
import { createHostDirBackend } from "@rivet-dev/agentos-core";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
const codexConfig = makeCodexAgentOsConfig(); import { HostRepositoryWorkspace } from "./host-repository";
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({ const workspace = agentOS<undefined, { token: string }>({
onBeforeConnect: (_context, params) => { onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) { if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection"); throw new Error("Unauthorized workspace connection");
} }
}, },
software: codexConfig.software, options: {
actionTimeout: 10 * 60 * 1000,
},
permissions: piConfig.permissions,
software: piConfig.software,
}); });
export const runtimeRegistry = setup({ use: { workspace } }); export const runtimeRegistry = setup({ use: { workspace } });
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const event = ( const event = (
sequence: number, sequence: number,
kind: ExecutionEvent["kind"], kind: ExecutionEvent["kind"],
@@ -44,16 +46,6 @@ const event = (
sequence, sequence,
}); });
const requireSuccess = (
result: { exitCode: number; stderr: string; stdout: string },
operation: string
) => {
if (result.exitCode !== 0) {
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
};
const executionError = ( const executionError = (
message: string, message: string,
reason: WorkAttemptExecutionError["reason"], reason: WorkAttemptExecutionError["reason"],
@@ -80,12 +72,6 @@ const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
return executionError(message, "HarnessFailed", false); return executionError(message, "HarnessFailed", false);
}; };
const gitAuthEnv = (username: string, credential: string) => ({
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.extraHeader",
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${credential}`).toString("base64")}`,
});
export const executeAgentOsAttempt = async ( export const executeAgentOsAttempt = async (
rawInput: unknown rawInput: unknown
): Promise<WorkAttemptExecutionResult> => { ): Promise<WorkAttemptExecutionResult> => {
@@ -106,59 +92,95 @@ export const executeAgentOsAttempt = async (
workspaceKey: input.workspaceKey, workspaceKey: input.workspaceKey,
}), }),
]; ];
const authEnv = gitAuthEnv( const hostRepository = new HostRepositoryWorkspace();
input.auth.username ?? const prepared = await hostRepository.prepare({
(input.auth.provider === "github" ? "x-access-token" : "git"), attemptId: input.attemptId,
input.auth.credential piHomeFiles: makePiHomeFiles({
); api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
await vm.mkdir("/workspace", { recursive: true }); baseUrl: env.AGENT_MODEL_BASE_URL,
if (!(await vm.exists("/workspace/repository/.git"))) { contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
events.push(event(1, "repository.cloning", "Cloning project repository")); maxTokens: env.AGENT_MODEL_MAX_TOKENS,
const clone = await vm.execArgv( model: env.AGENT_MODEL_NAME,
"git", provider: env.AGENT_MODEL_PROVIDER,
["clone", input.repositoryUrl, "/workspace/repository"],
{
cwd: "/workspace",
env: authEnv,
}
);
requireSuccess(clone, "Repository clone");
}
const checkout = await vm.exec(
`git fetch origin ${shellQuote(input.baseBranch)} && git checkout -B ${shellQuote(`zopu/${input.runId}`)} ${shellQuote(`origin/${input.baseBranch}`)}`,
{ cwd: "/workspace/repository", env: authEnv }
);
requireSuccess(checkout, "Repository checkout");
const baseRevision = requireSuccess(
await vm.execArgv("git", ["rev-parse", "HEAD"], {
cwd: "/workspace/repository",
}), }),
"Base revision lookup" });
events.push(
event(
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( events.push(
event(2, "repository.ready", "Repository checkout is ready", { event(2, "repository.ready", "Repository checkout is ready", {
baseRevision, baseRevision,
}) })
); );
const sessionId = `codex-${input.attemptId}`; const sessionId = `pi-${input.attemptId}`;
await vm.openSession({ await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
additionalInstructions: additionalInstructions:
"Work only inside /workspace/repository. Do not reveal credentials. Make the requested change and run focused verification. Do not push or open a pull request.", "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: "codex", agent: "pi",
cwd: "/workspace/repository", cwd: "/workspace/repository",
env: codexSessionEnv({ env: piSessionEnv(env.AGENT_MODEL_API_KEY),
apiKey: env.AGENT_MODEL_API_KEY,
baseUrl: env.AGENT_MODEL_BASE_URL,
model: env.AGENT_MODEL_NAME,
}),
permissionPolicy: "allow_all", permissionPolicy: "allow_all",
sessionId, sessionId,
}); });
events.push( events.push(
event(3, "harness.started", "Codex implementation session started") event(3, "harness.started", "Pi implementation session started")
); );
const promptResult = await vm.prompt({ const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }], content: [{ text: input.prompt, type: "text" }],
@@ -169,68 +191,24 @@ export const executeAgentOsAttempt = async (
const reason = const reason =
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed"; promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
throw executionError( throw executionError(
`Codex stopped with ${promptResult.stopReason}`, `Pi stopped with ${promptResult.stopReason}`,
reason, reason,
promptResult.stopReason === "max_tokens" || promptResult.stopReason === "max_tokens" ||
promptResult.stopReason === "max_turn_requests" promptResult.stopReason === "max_turn_requests"
); );
} }
events.push( events.push(
event(4, "harness.progress", "Codex implementation turn completed", { event(4, "harness.progress", "Pi implementation turn completed", {
stopReason: promptResult.stopReason, stopReason: promptResult.stopReason,
}) })
); );
const status = requireSuccess( const collected = await HostRepositoryWorkspace.collect({
await vm.execArgv("git", ["status", "--porcelain"], { attemptId: input.attemptId,
cwd: "/workspace/repository", baseRevision,
}), checkoutPath: prepared.checkoutPath,
"Changed file lookup" });
); const { candidateRevision, changedFiles, diff } = collected;
const diff = requireSuccess(
await vm.execArgv("git", ["diff", "--binary", "HEAD"], {
cwd: "/workspace/repository",
}),
"Diff collection"
);
const changedFiles = status
.split("\n")
.filter(Boolean)
.map((line) => line.slice(3).trim());
let candidateRevision = baseRevision;
if (changedFiles.length > 0) {
requireSuccess(
await vm.execArgv("git", ["add", "-A"], {
cwd: "/workspace/repository",
}),
"Candidate staging"
);
const tree = requireSuccess(
await vm.execArgv("git", ["write-tree"], {
cwd: "/workspace/repository",
}),
"Candidate tree creation"
);
candidateRevision = requireSuccess(
await vm.exec(
`printf %s ${shellQuote(`Zopu candidate for ${input.attemptId}`)} | git commit-tree ${shellQuote(tree)} -p ${shellQuote(baseRevision)}`,
{
cwd: "/workspace/repository",
env: {
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
GIT_AUTHOR_NAME: "Zopu Agent",
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
GIT_COMMITTER_NAME: "Zopu Agent",
},
}
),
"Candidate revision creation"
);
requireSuccess(
await vm.execArgv("git", ["reset"], { cwd: "/workspace/repository" }),
"Candidate index reset"
);
}
events.push( events.push(
event( event(
5, 5,
@@ -252,8 +230,8 @@ export const executeAgentOsAttempt = async (
events, events,
summary: summary:
changedFiles.length > 0 changedFiles.length > 0
? `Codex changed ${changedFiles.length} file(s)` ? `Pi changed ${changedFiles.length} file(s)`
: "Codex completed without repository changes", : "Pi completed without repository changes",
}; };
} catch (error) { } catch (error) {
throw classifyRuntimeFailure(error); throw classifyRuntimeFailure(error);
@@ -273,5 +251,5 @@ export const cancelAgentOsAttempt = async (
.getOrCreate([workspaceKey], { .getOrCreate([workspaceKey], {
params: { token: env.RIVET_WORKSPACE_TOKEN }, params: { token: env.RIVET_WORKSPACE_TOKEN },
}) })
.cancelPrompt({ sessionId: `codex-${attemptId}` }); .cancelPrompt({ sessionId: `pi-${attemptId}` });
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,9 +111,11 @@ export default defineSchema({
leaseOwner: v.optional(v.string()), leaseOwner: v.optional(v.string()),
status: v.union( status: v.union(
v.literal("queued"), v.literal("queued"),
v.literal("processing"), v.literal("dispatching"),
v.literal("running"),
v.literal("completed"), v.literal("completed"),
v.literal("failed") v.literal("failed"),
v.literal("aborted")
), ),
submissionId: v.optional(v.string()), submissionId: v.optional(v.string()),
}) })
@@ -121,7 +123,8 @@ export default defineSchema({
"conversationId", "conversationId",
"clientRequestId", "clientRequestId",
]) ])
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]), .index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"])
.index("by_submissionId", ["submissionId"]),
conversationMessages: defineTable({ conversationMessages: defineTable({
content: v.string(), content: v.string(),
conversationId: v.id("conversations"), conversationId: v.id("conversations"),
@@ -367,7 +370,6 @@ export default defineSchema({
workAttempts: defineTable({ workAttempts: defineTable({
classification: v.optional(attemptClassification), classification: v.optional(attemptClassification),
endedAt: v.optional(v.number()),
failureReason: v.optional( failureReason: v.optional(
v.union( v.union(
v.literal("Authentication"), v.literal("Authentication"),
@@ -379,6 +381,7 @@ export default defineSchema({
v.literal("Timeout") v.literal("Timeout")
) )
), ),
endedAt: v.optional(v.number()),
leaseExpiresAt: v.optional(v.number()), leaseExpiresAt: v.optional(v.number()),
leaseOwner: v.optional(v.string()), leaseOwner: v.optional(v.string()),
number: v.number(), number: v.number(),

View File

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

View File

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

View File

@@ -81,7 +81,7 @@ export const cancelAttempt = internalAction({
handler: async (_ctx, args) => { handler: async (_ctx, args) => {
// The workspace key remains the URL path segment (workspace identity), // The workspace key remains the URL path segment (workspace identity),
// while the body carries the attemptId so the runtime can target the // while the body carries the attemptId so the runtime can target the
// codex session `codex-${attemptId}` for cancellation. // matching Pi ACP session for cancellation.
await fetch( await fetch(
`${backendUrl()}/internal/work-attempts/${encodeURIComponent(args.workspaceKey)}/cancel`, `${backendUrl()}/internal/work-attempts/${encodeURIComponent(args.workspaceKey)}/cancel`,
{ {

View File

@@ -430,7 +430,7 @@ export const completeAttempt = internalMutation({
kind: "diff", kind: "diff",
metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }), metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }),
organizationId: work.organizationId, organizationId: work.organizationId,
producer: "agentos-codex", producer: "agentos-pi",
projectId: work.projectId, projectId: work.projectId,
provenanceJson: JSON.stringify({ provenanceJson: JSON.stringify({
baseRevision: args.result.baseRevision, baseRevision: args.result.baseRevision,

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { promisify } from "node:util";
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
import { GitBranchName } from "./git.js"; import { GitBranchName } from "./git.ts";
const execFileP = promisify(execFile); const execFileP = promisify(execFile);

View File

@@ -1,6 +1,6 @@
import { Effect, Schema } from "effect"; import { Effect, Schema } from "effect";
import type { AttemptOutcome } from "./resolver"; import type { AttemptOutcome } from "./resolver.ts";
const Text = Schema.String.check( const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {

View File

@@ -1,19 +1,19 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules. // oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os"; export * from "./agent-os.ts";
export * from "./execution-runtime"; export * from "./execution-runtime.ts";
export * from "./git"; export * from "./git.ts";
export * from "./git-local-runtime"; export * from "./git-local-runtime.ts";
export * from "./git-remote-runtime"; export * from "./git-remote-runtime.ts";
export * from "./project"; export * from "./project.ts";
export * from "./project-issue"; export * from "./project-issue.ts";
export * from "./project-workspace"; export * from "./project-workspace.ts";
export * from "./project-work"; export * from "./project-work.ts";
export * from "./signal"; export * from "./signal.ts";
export * from "./smoke"; export * from "./smoke.ts";
export * from "./work"; export * from "./work.ts";
export * from "./work-artifact"; export * from "./work-artifact.ts";
export * from "./work-definition"; export * from "./work-definition.ts";
export * from "./work-design"; export * from "./work-design.ts";
export * from "./work-lifecycle"; export * from "./work-lifecycle.ts";
export * from "./resolver"; export * from "./resolver.ts";
export * from "./harness-runtime"; export * from "./harness-runtime.ts";

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */ /* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect"; import { Effect, Schema } from "effect";
import { ProblemStatement, ProjectId, SignalId } from "./signal.js"; import { ProblemStatement, ProjectId, SignalId } from "./signal.ts";
const MeaningfulString = Schema.String.check( const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {

View File

@@ -1,4 +1,4 @@
import type { ProjectIssueStatus } from "./project-issue.js"; import type { ProjectIssueStatus } from "./project-issue.ts";
export const PROJECT_ISSUE_STATUSES = [ export const PROJECT_ISSUE_STATUSES = [
"open", "open",

View File

@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */ /* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */
import { Effect, Schema } from "effect"; import { Effect, Schema } from "effect";
import { CONTEXT_KINDS } from "./project.js"; import { CONTEXT_KINDS } from "./project.ts";
export type { ContextKind } from "./project.js"; export type { ContextKind } from "./project.ts";
const MeaningfulString = Schema.String.check( const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {

View File

@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */ /* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
import { OrganizationId, ProjectId, TimestampMs } from "./signal.js"; import { OrganizationId, ProjectId, TimestampMs } from "./signal.ts";
export type { OrganizationId, ProjectId } from "./signal.js"; export type { OrganizationId, ProjectId } from "./signal.ts";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Domain constants // Domain constants
@@ -649,7 +649,8 @@ export class ProjectApplication extends Context.Service<
const store = yield* ProjectStore; const store = yield* ProjectStore;
return ProjectApplication.of({ return ProjectApplication.of({
getProject: Effect.fn("ProjectApplication.getProject")(function* getProject({ getProject: Effect.fn("ProjectApplication.getProject")(
function* getProject({
userId, userId,
projectId, projectId,
}: { }: {
@@ -659,7 +660,8 @@ export class ProjectApplication extends Context.Service<
return yield* store return yield* store
.getProject(userId, projectId) .getProject(userId, projectId)
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
}), }
),
importPublicGit: Effect.fn("ProjectApplication.importPublicGit")( importPublicGit: Effect.fn("ProjectApplication.importPublicGit")(
function* importPublicGit({ function* importPublicGit({
userId, userId,
@@ -724,16 +726,15 @@ export class ProjectApplication extends Context.Service<
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
} }
), ),
listProjects: Effect.fn("ProjectApplication.listProjects")(function* listProjects({ listProjects: Effect.fn("ProjectApplication.listProjects")(
userId, function* listProjects({ userId }: { readonly userId: string }) {
}: {
readonly userId: string;
}) {
return yield* store return yield* store
.listProjects(userId) .listProjects(userId)
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
}), }
putContext: Effect.fn("ProjectApplication.putContext")(function* putContext({ ),
putContext: Effect.fn("ProjectApplication.putContext")(
function* putContext({
userId, userId,
projectId, projectId,
kind, kind,
@@ -755,7 +756,8 @@ export class ProjectApplication extends Context.Service<
return yield* store return yield* store
.putContext({ projectId, userId, write }) .putContext({ projectId, userId, write })
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
}), }
),
}); });
}) })
); );

View File

@@ -1,6 +1,6 @@
import { Schema } from "effect"; import { Schema } from "effect";
import type { WorkStatus } from "./work-lifecycle"; import type { WorkStatus } from "./work-lifecycle.ts";
const Text = Schema.String.check( const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {

View File

@@ -1,7 +1,7 @@
import { Array as EffectArray, Effect, Order, Schema } from "effect"; import { Array as EffectArray, Effect, Order, Schema } from "effect";
export { WorkStatus } from "./work-lifecycle"; export { WorkStatus } from "./work-lifecycle.ts";
export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle"; export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle.ts";
const MeaningfulString = Schema.String.check( const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {

View File

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

20928
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

62
pnpm-workspace.yaml Normal file
View File

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