Merge branch 'dogfood/v0' into dogfood/orb-runtime

This commit is contained in:
-Puter
2026-07-24 22:03:39 +05:30
24 changed files with 4443 additions and 53 deletions

View File

@@ -0,0 +1,53 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { LoaderCircle, MessagesSquare } from "lucide-react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import type { ChatAgentState } from "@/lib/chat/types";
interface ConversationPanelProps {
readonly agent: ChatAgentState;
readonly title: string;
readonly emptyHint: string;
}
export const ConversationPanel = ({
agent,
emptyHint,
title,
}: ConversationPanelProps) => {
if (agent.status === "connecting" && !agent.historyReady) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<LoaderCircle className="mr-2 size-4 animate-spin" />
Loading conversation
</div>
);
}
if (agent.historyReady && agent.messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<MessagesSquare className="size-6 text-muted-foreground/50" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
{emptyHint}
</p>
</div>
);
}
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="min-h-full gap-4 px-4 py-4">
{agent.messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{agent.status === "submitted" ? <ChatThinkingResponse /> : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,54 @@
import { Bot, FolderGit2 } from "lucide-react";
interface EmptyStateProps {
readonly onConnectRepository: () => void;
readonly busy: boolean;
readonly repository: string;
readonly onRepositoryChange: (value: string) => void;
}
export const EmptyState = ({
busy,
onConnectRepository,
onRepositoryChange,
repository,
}: EmptyStateProps) => (
<div className="grid min-h-[60vh] place-items-center">
<div className="max-w-md text-center">
<div className="mx-auto grid size-14 place-items-center rounded-2xl bg-foreground text-background">
<FolderGit2 className="size-6" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Connect a project to begin
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Import a repository to start the Work OS loop. Turn a clear outcome into
a Work Unit that Zopu can start, verify, and review.
</p>
<form
className="mx-auto mt-6 max-w-sm space-y-3"
onSubmit={(event) => {
event.preventDefault();
onConnectRepository();
}}
>
<input
aria-label="Public Git repository URL"
className="w-full rounded-xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onRepositoryChange(event.target.value)}
placeholder="https://github.com/owner/repo"
required
value={repository}
/>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
<Bot className="size-4" />
{busy ? "Connecting" : "Connect repository"}
</button>
</form>
</div>
</div>
);

View File

@@ -0,0 +1,71 @@
import { Radio } from "lucide-react";
import type { SignalItem } from "@/hooks/work-os/use-work-os";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
interface SignalsPanelProps {
readonly signals: readonly SignalItem[];
readonly loading: boolean;
}
export const SignalsPanel = ({ loading, signals }: SignalsPanelProps) => {
if (loading) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">Loading signals</p>
</section>
);
}
if (signals.length === 0) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">
No Signals detected for this project yet.
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<span className="text-[10px] text-muted-foreground/60">
{signals.length} total
</span>
</div>
<div className="space-y-3">
{signals.map((signal) => (
<div
className="rounded-xl border border-border/20 bg-background/30 p-3"
key={`${signal.title}-${signal.createdAt}`}
>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span>
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"}
</span>
<span>{formatRelativeTime(signal.createdAt)}</span>
</div>
</div>
))}
</div>
</section>
);
};

View File

@@ -0,0 +1,143 @@
import { Button } from "@code/ui/components/button";
import { LoaderCircle, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComposerMode } from "@/hooks/work-os/use-work-os";
interface WorkOsComposerProps {
readonly mode: ComposerMode;
readonly onModeChange: (mode: ComposerMode) => void;
readonly onSend: (message: string) => Promise<void>;
readonly busy: boolean;
readonly error?: Error;
readonly workUnitTitle?: string;
readonly placeholder?: string;
}
export const WorkOsComposer = ({
busy,
error,
mode,
onModeChange,
onSend,
placeholder,
workUnitTitle,
}: WorkOsComposerProps) => {
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
useEffect(() => {
if (!busy) {
textareaRef.current?.focus();
}
}, [busy, mode]);
const handleSubmit = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const scopeLabel =
mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit");
return (
<div className="border-t border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
{/* Mode switcher */}
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
<button
className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "project"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onModeChange("project")}
type="button"
>
Project
</button>
<button
className={`flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "work-unit"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
disabled={!workUnitTitle}
onClick={() => onModeChange("work-unit")}
type="button"
>
Work Unit
</button>
</div>
{mode === "work-unit" && workUnitTitle ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<X
className="size-3 cursor-pointer"
onClick={() => onModeChange("project")}
/>
<span className="max-w-40 truncate">{workUnitTitle}</span>
</span>
) : null}
<span className="ml-auto text-[10px] text-muted-foreground/50">
{scopeLabel}
</span>
</div>
{error ? (
<p className="mb-2 text-[11px] text-destructive" id="composer-error">
{error.message || "Message failed to send"}
</p>
) : null}
<div className="flex items-end gap-2.5">
<textarea
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label={`Message ${scopeLabel}`}
className="min-h-11 max-h-32 flex-1 resize-none rounded-2xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm leading-5 outline-none transition-colors placeholder:text-muted-foreground/60 focus-visible:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message ${scopeLabel}`}
ref={textareaRef}
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0 rounded-full"
disabled={!canSend}
onClick={() => void handleSubmit()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { ChevronDown, Circle, FolderGit2, Zap } from "lucide-react";
import UserMenu from "@/components/user-menu";
interface ProjectOption {
readonly id: string;
readonly name: string;
readonly host?: string;
}
interface WorkOsHeaderProps {
readonly projects: readonly ProjectOption[] | undefined;
readonly selectedProject: ProjectOption | null;
readonly onSelectProject: (id: string) => void;
readonly connected: boolean;
readonly onBack?: () => void;
readonly showBack: boolean;
}
const StatusDot = ({ connected }: { readonly connected: boolean }) => (
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Circle
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
/>
{connected ? "Connected" : "Connecting"}
</span>
);
export const WorkOsHeader = ({
connected,
onBack,
onSelectProject,
projects,
selectedProject,
showBack,
}: WorkOsHeaderProps) => (
<header className="flex items-center justify-between gap-3 border-b border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
<div className="flex min-w-0 items-center gap-3">
{showBack && onBack ? (
<Button
className="shrink-0"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronDown className="size-4 rotate-90" />
</Button>
) : null}
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-foreground text-background">
<Zap className="size-4 fill-current" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">Zopu</p>
<StatusDot connected={connected} />
</div>
{selectedProject ? (
<p className="truncate text-[11px] text-muted-foreground">
{selectedProject.name}
</p>
) : (
<p className="text-[11px] text-muted-foreground">No project</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{projects && projects.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm" type="button" variant="ghost">
<FolderGit2 className="size-3.5" />
<span className="max-w-32 truncate">
{selectedProject?.name ?? "Select"}
</span>
<ChevronDown className="size-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => onSelectProject(project.id)}
>
<FolderGit2 className="size-3.5" />
<span className="truncate">{project.name}</span>
{project.host ? (
<span className="ml-auto text-[10px] text-muted-foreground">
{project.host}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<UserMenu />
</div>
</header>
);

View File

@@ -0,0 +1,401 @@
import { CircleAlert, GitFork, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { ConversationPanel } from "@/components/work-os/conversation-panel";
import { EmptyState } from "@/components/work-os/empty-state";
import { SignalsPanel } from "@/components/work-os/signals-panel";
import { WorkOsComposer } from "@/components/work-os/work-os-composer";
import { WorkOsHeader } from "@/components/work-os/work-os-header";
import { WorkUnitCard } from "@/components/work-os/work-unit-card";
import { WorkUnitDetail } from "@/components/work-os/work-unit-detail";
import { useWorkOs } from "@/hooks/work-os/use-work-os";
import type { WorkOsState } from "@/hooks/work-os/use-work-os";
type CardData = WorkOsState["workUnitCards"][number];
type IssueId = WorkOsState["selectedIssueId"];
interface ProjectOption {
readonly host?: string;
readonly id: string;
readonly name: string;
}
const toProjectOption = (project: {
readonly id: string;
readonly name: string;
readonly sources: readonly { readonly host?: string }[];
}): ProjectOption => ({
host: project.sources[0]?.host,
id: project.id,
name: project.name,
});
const LoadingLine = ({ label }: { readonly label: string }) => (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{label}
</div>
);
const isAgentBusy = (status: string): boolean =>
status === "connecting" || status === "submitted" || status === "streaming";
const CONVERSATION_COPY = {
projectHint:
"Describe what you want Zopu to work on. Work Units created from the conversation will appear in the right panel.",
projectTitle: "Project conversation",
workUnitHint:
"Send a message to continue this Work Unit. The project manager agent will pick up the existing context.",
workUnitTitle: "Work Unit conversation",
} as const;
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
readonly onBodyChange: (value: string) => void;
readonly onSubmit: () => void;
readonly onTitleChange: (value: string) => void;
readonly title: string;
}
const IssueComposer = ({
body,
busy,
onBodyChange,
onSubmit,
onTitleChange,
title,
}: IssueComposerProps) => (
<form
className="rounded-2xl border border-border/40 bg-card/40 p-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="mb-3 flex items-center gap-2">
<div className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
<Plus className="size-3.5" />
</div>
<p className="text-sm font-semibold">Create a Work Unit</p>
</div>
<div className="space-y-2">
<input
aria-label="Work Unit title"
className="w-full rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onTitleChange(event.target.value)}
placeholder="Outcome title"
required
value={title}
/>
<textarea
aria-label="Work Unit description"
className="min-h-20 w-full resize-none rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onBodyChange(event.target.value)}
placeholder="Describe the desired result, constraints, or evidence."
required
rows={3}
value={body}
/>
</div>
<button
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
{busy ? "Creating" : "Create Work Unit"}
</button>
</form>
);
const WorkUnitCards = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-6 text-center">
<GitFork className="mx-auto size-4 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">No Work Units yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create one above, or describe what you need in the conversation.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
const MobileWorkUnitList = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-4 text-center">
<p className="text-xs text-muted-foreground">
No Work Units yet. Create one or chat with Zopu.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
interface NoProjectProps {
readonly busy: boolean;
readonly onConnectRepository: () => Promise<void>;
readonly onRepositoryChange: (value: string) => void;
readonly projects: WorkOsState["projects"];
readonly repository: string;
}
const NoProjectView = ({
busy,
onConnectRepository,
onRepositoryChange,
projects,
repository,
}: NoProjectProps) => {
if (projects === undefined) {
return (
<div className="grid h-full place-items-center">
<LoadingLine label="Loading projects" />
</div>
);
}
return (
<EmptyState
busy={busy}
onConnectRepository={onConnectRepository}
onRepositoryChange={onRepositoryChange}
repository={repository}
/>
);
};
export const WorkOsPage = () => {
const os = useWorkOs();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const handleSelectIssue = (issueId: IssueId) => {
os.selectIssue(issueId);
setMobileDetailOpen(issueId !== null);
};
const handleStart = () => {
if (!os.selectedIssue) {
return;
}
void os.startIssue(
os.selectedIssue._id,
os.selectedIssue.number,
os.selectedIssue.title
);
};
const handleRaiseIssue = () => {
void os.raiseIssue({ body: issueBody, title: issueTitle });
setIssueTitle("");
setIssueBody("");
};
const handleSend = (message: string): Promise<void> =>
os.composerMode === "work-unit" && os.selectedIssueId
? os.workUnitAgent.sendMessage(message)
: os.projectAgent.sendMessage(message);
const handleModeChange = (mode: "project" | "work-unit") =>
os.setComposerMode(mode);
const handleConnectRepository = os.connectRepository;
const handleRepositoryChange = os.setRepository;
const isWorkUnitMode =
os.composerMode === "work-unit" && !!os.selectedIssueId;
const activeAgent = isWorkUnitMode ? os.workUnitAgent : os.projectAgent;
const conversationTitle = isWorkUnitMode
? CONVERSATION_COPY.workUnitTitle
: CONVERSATION_COPY.projectTitle;
const conversationHint = isWorkUnitMode
? CONVERSATION_COPY.workUnitHint
: CONVERSATION_COPY.projectHint;
const selectedProjectOption = os.selectedProject
? toProjectOption(os.selectedProject)
: null;
const projectOptions = os.projects?.map(toProjectOption) ?? undefined;
const cards = os.workUnitCards ?? [];
const startPending = os.pendingAction === `issue:${os.selectedIssue?._id}`;
const showConversation = !(mobileDetailOpen && os.selectedDetail);
return (
<div className="flex h-svh flex-col bg-background text-foreground">
<WorkOsHeader
connected={!!os.selectedProject}
onBack={() => {
os.selectIssue(null);
setMobileDetailOpen(false);
}}
onSelectProject={() => os.selectIssue(null)}
projects={projectOptions}
selectedProject={selectedProjectOption}
showBack={mobileDetailOpen && !!os.selectedIssue}
/>
{os.error ? (
<div className="flex items-start gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<p>{os.error}</p>
</div>
) : null}
{os.selectedProject ? (
<main className="flex min-h-0 flex-1">
{/* Left column: conversation + composer */}
<div className="flex min-w-0 flex-1 flex-col">
<div
className={`min-h-0 flex-1 ${showConversation ? "block" : "hidden md:block"}`}
>
<ConversationPanel
agent={activeAgent}
emptyHint={conversationHint}
title={conversationTitle}
/>
</div>
{/* Mobile: detail overlay */}
{mobileDetailOpen && os.selectedDetail ? (
<div className="min-h-0 flex-1 overflow-y-auto md:hidden">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<WorkOsComposer
busy={isAgentBusy(activeAgent.status)}
error={
os.composerMode === "work-unit"
? os.workUnitAgent.error
: os.projectAgent.error
}
mode={os.composerMode}
onModeChange={handleModeChange}
onSend={handleSend}
workUnitTitle={os.selectedIssue?.title}
/>
</div>
{/* Right column: Work Units, signals, detail */}
<aside className="hidden w-[400px] shrink-0 flex-col overflow-y-auto border-l border-border/40 bg-background/50 p-4 md:flex lg:w-[440px]">
<IssueComposer
body={issueBody}
busy={os.pendingAction === "issue"}
onBodyChange={setIssueBody}
onSubmit={handleRaiseIssue}
onTitleChange={setIssueTitle}
title={issueTitle}
/>
<div className="mt-4">
<SignalsPanel
loading={os.signals === undefined}
signals={os.signals}
/>
</div>
{os.selectedDetail ? (
<div className="mt-4">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<div className="mt-4">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<WorkUnitCards
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</aside>
{/* Mobile: Work Unit cards list */}
<div className="border-t border-border/40 p-4 md:hidden">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<MobileWorkUnitList
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</main>
) : (
<main className="flex-1 overflow-y-auto">
<NoProjectView
busy={os.pendingAction === "connect"}
onConnectRepository={handleConnectRepository}
onRepositoryChange={handleRepositoryChange}
projects={os.projects}
repository={os.repository}
/>
</main>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import { Badge } from "@code/ui/components/badge";
import {
Activity,
ChevronRight,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitCard as WorkUnitCardData } from "@/lib/work-os/work-unit-projection";
const STATUS_LABEL: Record<WorkUnitCardData["status"], string> = {
completed: "Completed",
failed: "Failed",
"needs-input": "Needs input",
open: "Ready",
queued: "Queued",
working: "In progress",
};
const STATUS_TONE: Record<WorkUnitCardData["status"], string> = {
completed: "bg-emerald-500/15 text-emerald-400",
failed: "bg-destructive/15 text-destructive",
"needs-input": "bg-amber-500/15 text-amber-400",
open: "bg-muted text-muted-foreground",
queued: "bg-violet-500/15 text-violet-400",
working: "bg-blue-500/15 text-blue-400",
};
interface IndicatorProps {
readonly icon: React.ReactNode;
readonly label: string;
readonly value: number;
readonly tone?: string;
}
const Indicator = ({ icon, label, tone, value }: IndicatorProps) => (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className={tone}>{icon}</span>
{value}
<span className="sr-only">{label}</span>
</span>
);
interface WorkUnitCardProps {
readonly card: WorkUnitCardData;
readonly selected: boolean;
readonly onSelect: () => void;
}
export const WorkUnitCard = ({
card,
onSelect,
selected,
}: WorkUnitCardProps) => (
<button
className={`w-full cursor-pointer rounded-2xl border p-4 text-left transition-all duration-200 ${
selected
? "border-foreground/20 bg-foreground/5"
: "border-border/40 bg-card/40 hover:border-border/70 hover:bg-card/60"
}`}
onClick={onSelect}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-muted-foreground">
#{card.number}
</p>
<h3 className="mt-0.5 text-sm font-semibold leading-5 tracking-tight">
{card.title}
</h3>
</div>
<Badge
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[card.status]}`}
variant="secondary"
>
{STATUS_LABEL[card.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">
{card.summary}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-border/30 pt-3">
{card.currentActivity ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Activity className="size-3" />
{card.currentActivity}
</span>
) : null}
<Indicator
icon={<Radio className="size-3" />}
label="signals"
value={card.signalCount}
/>
<Indicator
icon={<Layers className="size-3" />}
label="steps"
value={card.stepCount}
/>
<Indicator
icon={<FileText className="size-3" />}
label="artifacts"
value={card.artifactCount}
/>
{card.hasPullRequest ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400">
<GitPullRequest className="size-3" />
PR
</span>
) : null}
{card.needsInput ? (
<span className="inline-flex items-center gap-1 text-[11px] text-amber-400">
<TriangleAlert className="size-3" />
Input needed
</span>
) : null}
<span className="ml-auto inline-flex items-center gap-1">
<span className="text-[11px] text-muted-foreground/70">
{formatRelativeTime(card.updatedAt)}
</span>
<ChevronRight className="size-3.5 text-muted-foreground/50" />
</span>
</div>
</button>
);

View File

@@ -0,0 +1,237 @@
import { Button } from "@code/ui/components/button";
import {
ArrowUpRight,
Bot,
ExternalLink,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
const formatStatus = (
status: WorkUnitDetailData["issue"]["status"]
): string => {
if (status === "needs-input") {
return "Needs input";
}
if (status === "working") {
return "In progress";
}
return status.charAt(0).toUpperCase() + status.slice(1);
};
interface WorkUnitDetailProps {
readonly detail: WorkUnitDetailData;
readonly onOpenPullRequest: (url: string) => void;
readonly onStart: () => void;
readonly startPending: boolean;
}
const Section = ({
children,
icon,
title,
}: {
readonly children: React.ReactNode;
readonly icon: React.ReactNode;
readonly title: string;
}) => (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{title}
</div>
{children}
</section>
);
export const WorkUnitDetail = ({
detail,
onOpenPullRequest,
onStart,
startPending,
}: WorkUnitDetailProps) => {
const { issue } = detail;
const canStart = issue.status === "open" || issue.status === "failed";
return (
<div className="space-y-4">
{/* Objective */}
<section className="rounded-2xl border border-border/40 bg-card/40 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">
Work Unit #{issue.number}
</p>
<h2 className="mt-1 text-lg font-semibold leading-7 tracking-tight">
{issue.title}
</h2>
</div>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium">
{formatStatus(issue.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{issue.body}
</p>
{canStart ? (
<Button
className="mt-4 w-full"
disabled={startPending}
onClick={onStart}
type="button"
>
<Bot className="size-4" />
{startPending ? "Starting" : "Start work"}
</Button>
) : null}
</section>
{/* Needs input alert */}
{detail.needsInput ? (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">
This work unit needs your input
</p>
<p className="mt-1 text-xs leading-5 text-amber-400/70">
Resolve the open question or decision to unblock the agent.
</p>
</div>
</div>
) : null}
{/* Latest summary */}
{detail.latestSummary ? (
<Section icon={<ArrowUpRight className="size-3" />} title="Latest">
<p className="text-sm leading-6">{detail.latestSummary}</p>
</Section>
) : null}
{/* Linked Signals — authenticated via signalIssueAttachments */}
<Section
icon={<Radio className="size-3" />}
title={`Signals (${detail.signalCount})`}
>
{detail.linkedSignals.length > 0 ? (
<div className="space-y-3">
{detail.linkedSignals.map((signal) => (
<div key={String(signal.signalId)}>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
{formatRelativeTime(signal.createdAt)}
</p>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit.
</p>
)}
</Section>
{/* Activity timeline */}
<Section
icon={<Layers className="size-3" />}
title={`Timeline (${detail.stepCount} steps)`}
>
{detail.activity.length > 0 ? (
<div className="space-y-4">
{detail.activity.map((item) => (
<div
className="flex gap-3"
key={`${item.kind}-${item.createdAt}`}
>
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/40" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{item.detail}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{item.time}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No activity recorded yet.
</p>
)}
</Section>
{/* Artifacts */}
<Section
icon={<FileText className="size-3" />}
title={`Artifacts (${detail.artifactCount})`}
>
{detail.artifactPaths.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detail.artifactPaths.map((path) => (
<span
className="inline-flex items-center gap-1.5 rounded-lg bg-muted/60 px-2.5 py-1 text-[11px]"
key={path}
>
<FileText className="size-3 text-muted-foreground" />
{path}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No artifacts produced yet.
</p>
)}
</Section>
{/* Pull request */}
{detail.hasPullRequest ? (
<Section
icon={<GitPullRequest className="size-3" />}
title="Pull request"
>
{detail.pullRequestUrl ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-sm font-medium text-emerald-300">
{detail.pullRequestNumber
? `PR #${detail.pullRequestNumber} ready for review`
: "Pull request ready"}
</p>
<Button
className="mt-3"
onClick={() =>
detail.pullRequestUrl &&
onOpenPullRequest(detail.pullRequestUrl)
}
size="sm"
type="button"
variant="outline"
>
<ExternalLink className="size-3.5" />
Review changes
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground">
A pull request was opened but the URL is unavailable.
</p>
)}
</Section>
) : null}
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueAgent } from "@flue/react";
import { useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import type { ChatAgentState } from "@/lib/chat/types";
import {
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
} from "@/lib/work-os/work-unit-projection";
import type {
LinkedSignal,
WorkUnitCard,
WorkUnitDetail,
} from "@/lib/work-os/work-unit-projection";
export interface SignalItem {
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export type ComposerMode = "project" | "work-unit";
export interface WorkOsState {
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType<
typeof useProjectWorkspace
>["selectedProject"];
readonly selectedProjectId: ReturnType<
typeof useProjectWorkspace
>["selectedProjectId"];
readonly repository: string;
readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>;
readonly workUnitCards: readonly WorkUnitCard[];
readonly selectedIssue: ReturnType<
typeof useProjectWorkspace
>["selectedIssue"];
readonly selectedIssueId: Id<"projectIssues"> | null;
readonly selectedDetail: WorkUnitDetail | null;
readonly selectIssue: (issueId: Id<"projectIssues"> | null) => void;
readonly startIssue: ReturnType<typeof useProjectWorkspace>["startIssue"];
readonly raiseIssue: ReturnType<typeof useProjectWorkspace>["raiseIssue"];
readonly issueTitle: string;
readonly setIssueTitle: (value: string) => void;
readonly issueBody: string;
readonly setIssueBody: (value: string) => void;
readonly signals: readonly SignalItem[];
readonly composerMode: ComposerMode;
readonly setComposerMode: (mode: ComposerMode) => void;
readonly projectAgent: ChatAgentState;
readonly workUnitAgent: ChatAgentState;
readonly pendingAction: string | null;
readonly error: string | null;
}
const toLinkedSignals = (
entries:
| readonly {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}[]
| undefined
): readonly LinkedSignal[] =>
(entries ?? []).map((entry) => ({
createdAt: entry.createdAt,
signalId: entry.signalId,
sourceCount: entry.sourceCount,
summary: entry.summary,
title: entry.title,
}));
export const useWorkOs = (): WorkOsState => {
const workspace = useProjectWorkspace();
const orgState = usePersonalOrganization();
const projectAgent = useChatAgent();
const activeProjectId = workspace.selectedProjectId;
const workUnitFlue = useFlueAgent({
id: workspace.selectedIssueId
? String(workspace.selectedIssueId)
: undefined,
live: "sse",
name: "project-manager",
});
const workUnitAgent: ChatAgentState = {
error: workUnitFlue.error,
historyReady: workUnitFlue.historyReady,
messages: workUnitFlue.messages,
sendMessage: workUnitFlue.sendMessage,
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
};
// Project-level signals for the global sidebar panel.
const allSignals = useQuery(
api.signals.list,
orgState.organizationId
? { organizationId: orgState.organizationId }
: "skip"
);
const signals = useMemo<readonly SignalItem[]>(() => {
if (!allSignals || !activeProjectId) {
return [];
}
return allSignals
.filter((entry) => entry.signal.projectId === activeProjectId)
.map((entry) => ({
createdAt: entry.signal.createdAt,
sourceCount: entry.sources.length,
summary: entry.signal.problemStatement.summary,
title: entry.signal.problemStatement.title,
}));
}, [allSignals, activeProjectId]);
// All linked signals for the active project, grouped by issue id.
// Used for card counts and detail display.
const linkedByIssue = useQuery(
api.signals.listLinkedSignalsForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
workspace.setSelectedIssueId(issueId);
setComposerMode(issueId ? "work-unit" : "project");
};
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
if (!workspace.issues) {
return [];
}
return workspace.issues.map((issue) => {
const issueEvents = eventsForIssue(workspace.events, issue._id);
const linked = toLinkedSignals(linkedByIssue?.[String(issue._id)]);
return buildWorkUnitCard({
issue,
issueEvents,
linkedSignals: linked,
});
});
}, [workspace.issues, workspace.events, linkedByIssue]);
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
if (!workspace.selectedIssue) {
return null;
}
const issueEvents = eventsForIssue(
workspace.events,
workspace.selectedIssue._id
);
const linked = toLinkedSignals(
linkedByIssue?.[String(workspace.selectedIssue._id)]
);
return buildWorkUnitDetail({
issue: workspace.selectedIssue,
issueEvents,
linkedSignals: linked,
});
}, [workspace.selectedIssue, workspace.events, linkedByIssue]);
return {
composerMode,
connectRepository: workspace.connectRepository,
error: workspace.error,
issueBody: workspace.issueBody,
issueTitle: workspace.issueTitle,
pendingAction: workspace.pendingAction,
projectAgent,
projects: workspace.projects,
raiseIssue: workspace.raiseIssue,
repository: workspace.repository,
selectIssue,
selectedDetail,
selectedIssue: workspace.selectedIssue,
selectedIssueId: workspace.selectedIssueId,
selectedProject: workspace.selectedProject,
selectedProjectId: activeProjectId,
setComposerMode,
setIssueBody: workspace.setIssueBody,
setIssueTitle: workspace.setIssueTitle,
setRepository: workspace.setRepository,
signals,
startIssue: workspace.startIssue,
workUnitAgent,
workUnitCards,
};
};

View File

@@ -0,0 +1,484 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildActivityTimeline,
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
extractArtifactPaths,
extractLatestSummary,
extractPullRequestFromEvents,
} from "./work-unit-projection";
import type { LinkedSignal } from "./work-unit-projection";
type Issue = Doc<"projectIssues">;
type Event = Doc<"projectEvents">;
const ISSUE_A = "issue-a" as Id<"projectIssues">;
const ISSUE_B = "issue-b" as Id<"projectIssues">;
const makeLinkedSignal = (
overrides: Partial<LinkedSignal> = {}
): LinkedSignal => ({
createdAt: 5000,
signalId: "sig-1" as Id<"signals">,
sourceCount: 2,
summary: "Three users hit the same error.",
title: "OAuth crash on Safari",
...overrides,
});
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
({
_creationTime: 1,
_id: ISSUE_A,
body: "Fix the Safari OAuth callback so users can complete sign-in.",
createdAt: 1000,
number: 1,
projectId: "project-1" as Id<"projects">,
status: "open",
title: "Fix Safari OAuth callback",
updatedAt: 2000,
...overrides,
}) as Issue;
const makeEvent = (
kind: string,
data: Record<string, unknown>,
overrides: Partial<Event> = {}
): Event =>
({
_creationTime: 1,
_id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">,
createdAt: overrides.createdAt ?? 1000,
data,
issueId: ISSUE_A,
kind,
projectId: "project-1" as Id<"projects">,
...overrides,
}) as Event;
describe("eventsForIssue", () => {
test("filters to a specific issue and ignores undefined", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 2000, issueId: ISSUE_B }
),
];
expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1);
expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]);
});
});
describe("extractArtifactPaths", () => {
test("collects distinct paths from artifact.updated events", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 3000 }
),
makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }),
];
expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]);
});
test("returns empty for no artifact events", () => {
expect(extractArtifactPaths([])).toEqual([]);
});
});
describe("buildWorkUnitCard", () => {
test("projects a fresh issue with no events and no linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.title).toBe("Fix Safari OAuth callback");
expect(card.status).toBe("open");
expect(card.stepCount).toBe(0);
expect(card.currentActivity).toBeNull();
expect(card.needsInput).toBe(false);
expect(card.hasPullRequest).toBe(false);
expect(card.artifactCount).toBe(0);
expect(card.signalCount).toBe(0);
expect(card.summary).toContain("Fix the Safari OAuth");
});
test("counts per-issue artifacts from events, not project-wide", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "working" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.artifactCount).toBe(2);
expect(card.stepCount).toBe(2);
});
test("reflects needs-input status and activity from events", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }),
makeEvent("agent.needs-input", {}, { createdAt: 3000 }),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "needs-input" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.needsInput).toBe(true);
expect(card.stepCount).toBe(3);
expect(card.currentActivity).toBe("Needs your input");
});
test("detects PR from gitea event", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"gitea.pull_request.created",
{
pullRequest: { number: 42, url: "https://git.example.com/pulls/42" },
},
{ createdAt: 5000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.hasPullRequest).toBe(true);
});
});
describe("linked signal projection", () => {
test("card signalCount reflects real linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
title: "Build error",
}),
],
});
expect(card.signalCount).toBe(2);
});
test("card signalCount is 0 when no signals are linked", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.signalCount).toBe(0);
});
test("detail exposes linked signals with title, summary, and source count", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
sourceCount: 5,
summary: "CI broke",
}),
],
});
expect(detail.signalCount).toBe(2);
expect(detail.linkedSignals).toHaveLength(2);
expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari");
expect(detail.linkedSignals[0].sourceCount).toBe(2);
expect(detail.linkedSignals[1].sourceCount).toBe(5);
});
test("detail linked signals empty when no attachment exists", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(detail.signalCount).toBe(0);
expect(detail.linkedSignals).toEqual([]);
});
});
describe("extractLatestSummary", () => {
test("returns the most recent agent summary", () => {
const events = [
makeEvent("agent.working", {}, { createdAt: 1000 }),
makeEvent(
"agent.completed",
{ summary: "All tests passed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("All tests passed.");
});
test("returns null when no terminal agent event exists", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
];
expect(extractLatestSummary(events)).toBeNull();
});
test("prefers failure error over earlier completion", () => {
const events = [
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }),
makeEvent(
"agent.failed",
{ error: "Tests failed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("Tests failed.");
});
});
describe("extractPullRequestFromEvents", () => {
test("extracts URL and number from gitea event", () => {
const events = [
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } },
{ createdAt: 1000 }
),
];
const info = extractPullRequestFromEvents(events);
expect(info.hasPR).toBe(true);
expect(info.url).toBe("https://git.example.com/pulls/7");
expect(info.number).toBe(7);
});
test("returns false when no PR event exists", () => {
expect(extractPullRequestFromEvents([]).hasPR).toBe(false);
});
});
describe("buildActivityTimeline", () => {
test("sorts newest first and maps labels", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }),
];
const timeline = buildActivityTimeline(events);
expect(timeline).toHaveLength(2);
expect(timeline[0].kind).toBe("agent.completed");
expect(timeline[0].label).toBe("Work completed");
expect(timeline[1].kind).toBe("issue.created");
expect(timeline[1].label).toBe("Work created");
});
test("maps signal.attached event to activity", () => {
const events = [
makeEvent(
"signal.attached",
{ signalId: "sig-1", signalProblemTitle: "OAuth crash" },
{ createdAt: 3000 }
),
];
const timeline = buildActivityTimeline(events);
expect(timeline[0].label).toBe("Signal linked");
expect(timeline[0].detail).toContain("OAuth crash");
});
});
describe("buildWorkUnitDetail", () => {
test("aggregates all detail fields from per-issue events", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500 }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } },
{ createdAt: 3000 }
),
];
const detail = buildWorkUnitDetail({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [makeLinkedSignal()],
});
expect(detail.stepCount).toBe(4);
expect(detail.artifactCount).toBe(2);
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
expect(detail.signalCount).toBe(1);
expect(detail.hasPullRequest).toBe(true);
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
expect(detail.pullRequestNumber).toBe(3);
expect(detail.activity).toHaveLength(4);
expect(detail.activity[0].kind).toBe("gitea.pull_request.created");
});
});
// ---------------------------------------------------------------------------
// Cross-issue isolation: one issue must never inherit another issue's
// artifacts, PR, signals, or activity.
// ---------------------------------------------------------------------------
describe("cross-issue isolation", () => {
const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> });
const SIGNAL_B = makeLinkedSignal({
signalId: "sig-b" as Id<"signals">,
title: "Different signal",
});
const sharedEvents: Event[] = [
makeEvent(
"issue.created",
{ number: 1 },
{ createdAt: 1000, issueId: ISSUE_A }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 2000, issueId: ISSUE_A }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } },
{ createdAt: 3000, issueId: ISSUE_A }
),
makeEvent(
"agent.completed",
{ summary: "Done for A." },
{ createdAt: 4000, issueId: ISSUE_A }
),
// Issue B events — completely separate
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 1500, issueId: ISSUE_B }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500, issueId: ISSUE_B }
),
makeEvent(
"agent.failed",
{ error: "Failed for B." },
{ createdAt: 3500, issueId: ISSUE_B }
),
];
test("issue A does not inherit issue B's artifacts", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const cardA = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(cardA.artifactCount).toBe(1);
expect(cardA.stepCount).toBe(4);
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [SIGNAL_B],
});
expect(cardB.artifactCount).toBe(1);
expect(cardB.stepCount).toBe(3);
});
test("issue A does not inherit issue B's PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(cardB.hasPullRequest).toBe(false);
});
test("issue A does not inherit issue B's summary", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
expect(extractLatestSummary(eventsA)).toBe("Done for A.");
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
});
test("detail for issue A lists only issue A's artifact paths", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(detailA.artifactPaths).toEqual(["work.md"]);
expect(detailA.pullRequestNumber).toBe(5);
});
test("detail for issue B lists only issue B's artifact paths and no PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
expect(detailB.hasPullRequest).toBe(false);
expect(detailB.pullRequestUrl).toBeNull();
});
test("issue A does not inherit issue B's linked signals", () => {
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
linkedSignals: [SIGNAL_A],
});
expect(detailA.signalCount).toBe(1);
expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
linkedSignals: [SIGNAL_B],
});
expect(detailB.signalCount).toBe(1);
expect(detailB.linkedSignals[0].title).toBe("Different signal");
});
});

View File

@@ -0,0 +1,329 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
// ---------------------------------------------------------------------------
// Work Unit card projection — pure functions that turn Convex documents into
// the collapsed/expanded Work Unit views used by the Work OS surface.
//
// Counts are derived from issue-scoped evidence: events for artifacts/steps/
// PRs, and authenticated signalIssueAttachments for linked Signals. We never
// use project-wide artifact lists or signal lists.
// ---------------------------------------------------------------------------
export type ProjectEvent = Doc<"projectEvents">;
export type ProjectIssue = Doc<"projectIssues">;
/** A Signal linked to a Work Unit via signalIssueAttachments. */
export interface LinkedSignal {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export interface WorkUnitCard {
readonly issueId: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly status: ProjectIssue["status"];
readonly summary: string;
readonly signalCount: number;
readonly currentActivity: string | null;
readonly stepCount: number;
readonly artifactCount: number;
readonly hasPullRequest: boolean;
readonly needsInput: boolean;
readonly updatedAt: number;
}
export interface WorkUnitActivityItem {
readonly label: string;
readonly detail: string;
readonly time: string;
readonly kind: string;
readonly createdAt: number;
}
export interface WorkUnitDetail {
readonly issue: ProjectIssue;
readonly activity: readonly WorkUnitActivityItem[];
readonly stepCount: number;
readonly artifactCount: number;
readonly artifactPaths: readonly string[];
readonly signalCount: number;
readonly linkedSignals: readonly LinkedSignal[];
readonly hasPullRequest: boolean;
readonly pullRequestUrl: string | null;
readonly pullRequestNumber: number | null;
readonly needsInput: boolean;
readonly latestSummary: string | null;
}
// ---------------------------------------------------------------------------
// Time formatting
// ---------------------------------------------------------------------------
export const formatRelativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
export const formatClockTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
// ---------------------------------------------------------------------------
// Event filtering
// ---------------------------------------------------------------------------
export const eventsForIssue = (
events: readonly ProjectEvent[] | undefined,
issueId: Id<"projectIssues">
): readonly ProjectEvent[] =>
(events ?? []).filter((e) => e.issueId === issueId);
// ---------------------------------------------------------------------------
// Event-to-activity projection
// ---------------------------------------------------------------------------
interface ActivityProjection {
readonly label: string;
readonly detail: string;
}
const getString = (
data: Record<string, unknown>,
key: string,
fallback: string
): string => (typeof data[key] === "string" ? (data[key] as string) : fallback);
const ACTIVITY_HANDLERS: Record<
string,
(data: Record<string, unknown>, event: ProjectEvent) => ActivityProjection
> = {
"agent.completed": (data) => ({
detail: getString(data, "summary", "Run completed successfully"),
label: "Work completed",
}),
"agent.failed": (data) => ({
detail: getString(data, "error", "The run stopped unexpectedly"),
label: "Run failed",
}),
"agent.needs-input": () => ({
detail: "Waiting for a decision or missing information",
label: "Needs your input",
}),
"agent.working": () => ({
detail: "The project manager is progressing the issue",
label: "Agent working",
}),
"agent.workspace.created": (data) => ({
detail: `Workspace ready on ${getString(data, "branchName", "work branch")}`,
label: "Workspace prepared",
}),
"artifact.updated": (data) => ({
detail: `Evidence revised in ${getString(data, "path", "artifact")} (r${data.revision ?? "?"})`,
label: "Evidence updated",
}),
"gitea.lifecycle.updated": (data) => ({
detail: `${getString(data, "status", "updated")} on ${getString(data, "branch", "branch")}`,
label: "Git lifecycle",
}),
"gitea.pull_request.created": (data) => {
const pr = data.pullRequest as { number?: number } | undefined;
return {
detail: `Pull request #${pr?.number ?? "?"} opened for review`,
label: "Pull request opened",
};
},
"issue.created": (data) => ({
detail: `Issue #${data.number ?? "?"} entered the loop`,
label: "Work created",
}),
"issue.queued": (data) => ({
detail: `Queued #${data.number ?? "?"} for the project manager`,
label: "Work queued",
}),
"signal.attached": (data) => ({
detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`,
label: "Signal linked",
}),
};
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
const data = (event.data ?? {}) as Record<string, unknown>;
const handler = ACTIVITY_HANDLERS[event.kind];
if (handler) {
return handler(data, event);
}
return { detail: event.kind, label: "Activity" };
};
export const buildActivityTimeline = (
issueEvents: readonly ProjectEvent[]
): readonly WorkUnitActivityItem[] =>
[...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.map((event) => {
const projection = projectEventToActivity(event);
return {
createdAt: event.createdAt,
detail: projection.detail,
kind: event.kind,
label: projection.label,
time: formatRelativeTime(event.createdAt),
};
});
// ---------------------------------------------------------------------------
// PR extraction from issue-scoped events
// ---------------------------------------------------------------------------
export interface PullRequestInfo {
readonly hasPR: boolean;
readonly url: string | null;
readonly number: number | null;
}
export const extractPullRequestFromEvents = (
issueEvents: readonly ProjectEvent[]
): PullRequestInfo => {
const prEvent = issueEvents.find(
(e) => e.kind === "gitea.pull_request.created"
);
if (!prEvent) {
return { hasPR: false, number: null, url: null };
}
const data = prEvent.data as {
pullRequest?: { url?: string; number?: number };
};
const pr = data.pullRequest;
return {
hasPR: true,
number: pr?.number ?? null,
url: pr?.url ?? null,
};
};
// ---------------------------------------------------------------------------
// Latest agent summary
// ---------------------------------------------------------------------------
export const extractLatestSummary = (
issueEvents: readonly ProjectEvent[]
): string | null => {
const summaryEvent = [...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.find((e) => e.kind === "agent.completed" || e.kind === "agent.failed");
if (!summaryEvent) {
return null;
}
const data = summaryEvent.data as { summary?: string; error?: string };
if (summaryEvent.kind === "agent.completed") {
return data.summary ?? null;
}
return data.error ?? null;
};
// ---------------------------------------------------------------------------
// Artifact path extraction from issue-scoped artifact.updated events
// ---------------------------------------------------------------------------
export const extractArtifactPaths = (
issueEvents: readonly ProjectEvent[]
): readonly string[] => {
const paths = new Set<string>();
for (const event of issueEvents) {
if (event.kind !== "artifact.updated") {
continue;
}
const data = event.data as { path?: string };
if (typeof data.path === "string") {
paths.add(data.path);
}
}
return [...paths].toSorted();
};
// ---------------------------------------------------------------------------
// Card builder — derives counts from issue-scoped events + linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitCard = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitCard => {
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
const [latestEvent] = sorted;
const currentActivity = latestEvent
? projectEventToActivity(latestEvent).label
: null;
const prInfo = extractPullRequestFromEvents(issueEvents);
const summary = extractLatestSummary(issueEvents) ?? issue.body.slice(0, 140);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
artifactCount: artifactPaths.length,
currentActivity,
hasPullRequest: prInfo.hasPR,
issueId: issue._id,
needsInput: issue.status === "needs-input",
number: issue.number,
signalCount: linkedSignals.length,
status: issue.status,
stepCount: issueEvents.length,
summary,
title: issue.title,
updatedAt: issue.updatedAt,
};
};
// ---------------------------------------------------------------------------
// Detail builder — same per-issue derivation, plus linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitDetail = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitDetail => {
const activity = buildActivityTimeline(issueEvents);
const prInfo = extractPullRequestFromEvents(issueEvents);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
activity,
artifactCount: artifactPaths.length,
artifactPaths,
hasPullRequest: prInfo.hasPR,
issue,
latestSummary: extractLatestSummary(issueEvents),
linkedSignals,
needsInput: issue.status === "needs-input",
pullRequestNumber: prInfo.number,
pullRequestUrl: prInfo.url,
signalCount: linkedSignals.length,
stepCount: issueEvents.length,
};
};

View File

@@ -68,7 +68,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<Meta />
<Links />
</head>
<body className="bg-[#11110f]">
<body className="bg-[#0e0e0d]">
{children}
<ScrollRestoration />
<Scripts />
@@ -81,8 +81,8 @@ const App = () => (
<AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
forcedTheme="light"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>

View File

@@ -1,5 +1,5 @@
import { ProjectWorkspacePage } from "@/components/projects/project-workspace-page";
import { WorkOsPage } from "@/components/work-os/work-os-page";
export default function Dashboard() {
return <ProjectWorkspacePage />;
return <WorkOsPage />;
}

View File

@@ -2,30 +2,75 @@ import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos";
import { local } from "@flue/runtime/node";
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" };
import { paseoCli } from "../tools/paseo";
import { createSignalRoutingTools } from "../tools/signals";
const repositoryRoot = path.resolve(process.cwd(), "../..");
const INSTRUCTIONS = `You are Zopu, the global planning and work-routing agent for the Zopu Work OS.
## Your role
You listen to user messages in the persistent global conversation and decide whether they contain actionable work. The control plane stores every user message as exact evidence before you process it; you never supply or rewrite raw message 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.
2. **Identify project context.** If the message references a project, call list_projects to find it. If the user has one project, use it. If the project is ambiguous, ask one focused clarification.
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 (or if one already exists):
a. Call list_active_issues for the relevant project.
b. Compare the Signal's problem to existing issues.
c. If the Signal clearly relates to an existing active issue, call attach_signal_to_issue.
d. If the Signal is new work that does not match any existing issue, call create_issue_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:
- "Created a Signal: [title] and attached it to issue #[number]: [issue title]."
- "Created a Signal: [title] and opened new issue #[number]: [issue title]."
- Include the problem statement title so the user can verify accuracy.
6. **Optionally begin work.** Only when the user explicitly asks to start or work on the issue, call begin_issue. Do not begin work automatically.
## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
- 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 create Candidate Work, Work Units, or Runs directly. Those are downstream concerns.
- When the user asks about existing work, use list_active_issues and list_recent_signals to answer.`;
export { authenticatedAgentRoute as route } from "../auth";
export default defineAgent(({ env }) => {
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
cwd: repositoryRoot,
description: "A simple conversational agent with persistent history.",
instructions:
"You are Zopu, the global planning agent. Help the user clarify and plan work. User messages are durably captured as Signal evidence by the control plane; do not claim that you created a Signal until secure tool execution is wired.",
description:
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(),
sandbox: local({ cwd: repositoryRoot }),
skills: [paseo],
tools: [paseoCli],
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
};
});

View File

@@ -0,0 +1,317 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
// ---------------------------------------------------------------------------
// Agent-gated Convex function references for the routing loop.
// ---------------------------------------------------------------------------
const listEvidenceRef = makeFunctionReference<
"query",
{ readonly organizationId: string; readonly token: string },
{
messageId: string;
rawText: string;
createdAt: number;
submissionId: string | null;
}[]
>("signalRouting:listEvidence");
const createSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly projectId?: string;
messageIds: string[];
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
constraints: string[];
};
readonly processedByAgentInstanceId: string;
readonly token: string;
},
{ signalId: string }
>("signalRouting:createSignal");
const listSignalsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId?: string;
readonly token: string;
},
{
_id: string;
createdAt: number;
problemStatement: {
title: string;
summary: string;
desiredOutcome: string;
constraints: string[];
};
projectId: string | null;
}[]
>("signalRouting:listSignals");
const listActiveIssuesRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
_id: string;
number: number;
title: string;
body: string;
status: string;
projectId: string;
updatedAt: number;
}[]
>("signalRouting:listActiveIssues");
const attachSignalToIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly issueId: string;
readonly token: string;
},
{ attachmentId: string; alreadyAttached: boolean }
>("signalRouting:attachSignalToIssue");
const createIssueFromSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly token: string;
},
{ issueId: string; projectId: string }
>("signalRouting:createIssueFromSignal");
const beginIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly issueId: string;
readonly token: string;
},
"queued" | "working"
>("signalRouting:beginIssue");
const getProjectContextRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
project: {
_id: string;
name: string;
description: string | null;
organizationId: string;
};
contextDocuments: {
kind: string;
path: string;
content: string;
revision: number;
}[];
}
>("signalRouting:getProjectContext");
const listProjectsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly token: string;
},
{
_id: string;
name: string;
description: string | null;
organizationId: string;
}[]
>("signalRouting:listProjects");
// ---------------------------------------------------------------------------
// Tool factory: creates all routing tools bound to one agent instance.
//
// The `instanceId` is the organization ID (established by the authenticated
// route middleware). The tool never accepts organization ID as free input;
// it always comes from the bound instance. This is the hard tenancy boundary.
// ---------------------------------------------------------------------------
export const createSignalRoutingTools = (
instanceId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
// The instance id is the organization id; tools are scoped to it.
const organizationId = instanceId;
return [
defineTool({
description:
"List projects in the current organization. Call this first to identify the project context the user is working in.",
name: "list_projects",
async run() {
return await client.query(listProjectsRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Read the project name, description, and canonical context documents (README, product, design, tech, agents). Use this to understand the project before routing work.",
input: v.object({
projectId: v.string(),
}),
name: "get_project_context",
async run({ input }) {
return await client.query(getProjectContextRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"List admitted user messages in the current conversation that have not yet been consumed by a Signal. These are the candidate evidence messages. Raw text is exact — never modify it.",
name: "list_signal_evidence",
async run() {
return await client.query(listEvidenceRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Create a Signal from one or more admitted user messages plus a structured problem statement. Use ONLY when the conversation contains an actionable problem, request, blocker, or decision. Do NOT create Signals for casual chat, greetings, or exploration without a concrete problem. The problemStatement must faithfully represent the user's own words — do not invent or rewrite their intent.",
input: v.object({
messageIds: v.array(v.string()),
problemStatement: v.object({
constraints: v.array(v.string()),
desiredOutcome: v.string(),
summary: v.string(),
title: v.string(),
}),
projectId: v.optional(v.string()),
}),
name: "create_signal",
async run({ input }) {
return await client.mutation(createSignalRef, {
messageIds: input.messageIds,
organizationId,
problemStatement: input.problemStatement,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
processedByAgentInstanceId: organizationId,
token,
});
},
}),
defineTool({
description:
"List recent Signals for a project (or organization-wide if no projectId). Use this to see what has already been captured.",
input: v.object({
projectId: v.optional(v.string()),
}),
name: "list_recent_signals",
async run({ input }) {
return await client.query(listSignalsRef, {
organizationId,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
token,
});
},
}),
defineTool({
description:
"List active (open, queued, working, needs-input) ProjectIssues for a project. Use this to find existing issues that a new Signal might relate to before deciding whether to attach or create a new one.",
input: v.object({
projectId: v.string(),
}),
name: "list_active_issues",
async run({ input }) {
return await client.query(listActiveIssuesRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"Attach a Signal to an existing ProjectIssue. This links the signal's evidence to an open issue. Idempotent: repeating the same attachment is safe and returns the existing relation. Use when the signal's problem clearly relates to an existing active issue.",
input: v.object({
issueId: v.string(),
signalId: v.string(),
}),
name: "attach_signal_to_issue",
async run({ input }) {
return await client.mutation(attachSignalToIssueRef, {
issueId: input.issueId,
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Create a new ProjectIssue from a Signal. The signal must be project-scoped. This also auto-attaches the signal to the new issue. Use when the signal's problem does not match any existing active issue.",
input: v.object({
signalId: v.string(),
}),
name: "create_issue_from_signal",
async run({ input }) {
return await client.mutation(createIssueFromSignalRef, {
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Begin working on a ProjectIssue by transitioning it to queued. Use only after the user explicitly confirms they want to start the issue. Returns the new status.",
input: v.object({
issueId: v.string(),
}),
name: "begin_issue",
async run({ input }) {
return await client.mutation(beginIssueRef, {
issueId: input.issueId,
organizationId,
token,
});
},
}),
];
};

View File

@@ -0,0 +1,387 @@
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string }
) => {
const org = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as Id<"organizations">;
};
const problemStatement = {
title: "Deploy fails on staging",
summary: "The staging deploy command exits with a DNS resolution error.",
desiredOutcome: "Staging deploys successfully without DNS errors.",
constraints: ["Must not change the production pipeline"],
};
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
const seedMessage = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
const begun = await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: orgId,
rawText,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: orgId,
submissionId: `sub-${clientRequestId}`,
});
return begun.messageId;
};
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
suffix: string
): Promise<Id<"projects">> => {
const ownerIdentity = { tokenIdentifier: ownerId };
await t
.withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: `test-repo-${suffix}`,
repositoryPath: `owner-${suffix}/test-repo`,
normalizedUrl: `https://github.com/owner-${suffix}/test-repo`,
url: `https://github.com/owner-${suffix}/test-repo`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: "# test\n",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
// ---------------------------------------------------------------------------
// Helpers for creating signals and attachments via the DB directly (the
// query under test is read-only; we seed through mutations/DB).
// ---------------------------------------------------------------------------
const createSignalInProject = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
projectId: Id<"projects">,
msgSuffix: string,
ps: typeof problemStatement
): Promise<Id<"signals">> => {
const messageId = await seedMessage(
t,
identity,
orgId,
`req-${msgSuffix}`,
`evidence ${msgSuffix}`
);
const result = await t
.withIdentity(identity)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId],
organizationId: orgId,
problemStatement: ps,
projectId,
processedBy,
});
return result.signalId;
};
/** Insert a signalIssueAttachment directly via the test DB. */
const linkSignalToIssue = async (
t: TestConvex<typeof schema>,
signalId: Id<"signals">,
issueId: Id<"projectIssues">,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<void> => {
await t.mutation((ctx) =>
ctx.db.insert("signalIssueAttachments", {
createdAt: Date.now(),
issueId,
organizationId,
projectId,
signalId,
})
);
};
const createIssue = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
projectId: Id<"projects">,
title: string
): Promise<Id<"projectIssues">> =>
t.withIdentity(identity).mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title,
});
describe("listLinkedSignalsForProject", () => {
test("groups linked signals by issue with correct source counts", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "group");
const issue1 = await createIssue(t, identityA, projectId, "Issue one");
const issue2 = await createIssue(t, identityA, projectId, "Issue two");
// Signal 1: one source, linked to issue1
const sig1 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"s1",
problemStatement
);
await linkSignalToIssue(t, sig1, issue1, orgId, projectId);
// Signal 2: one source, linked to issue2
const sig2 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"s2",
{
...problemStatement,
title: "Different signal",
}
);
await linkSignalToIssue(t, sig2, issue2, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(2);
expect(result[String(issue1)]).toHaveLength(1);
expect(result[String(issue2)]).toHaveLength(1);
expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging");
expect(result[String(issue2)]![0]!.title).toBe("Different signal");
expect(result[String(issue1)]![0]!.sourceCount).toBe(1);
});
test("multiple signals linked to one issue are returned newest first", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "order");
const issue = await createIssue(t, identityA, projectId, "Single issue");
const ps1 = { ...problemStatement, title: "First signal" };
const ps2 = { ...problemStatement, title: "Second signal" };
const sig1 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"order1",
ps1
);
// Small delay so createdAt differs.
await t.mutation(async (ctx) => {
// Patch createdAt to be earlier.
const s = await ctx.db.get(sig1);
if (s) {
await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 });
}
});
const sig2 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"order2",
ps2
);
await linkSignalToIssue(t, sig1, issue, orgId, projectId);
await linkSignalToIssue(t, sig2, issue, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
const linked = result[String(issue)]!;
expect(linked).toHaveLength(2);
// Newest first: sig2 was created later.
expect(linked[0]!.title).toBe("Second signal");
expect(linked[1]!.title).toBe("First signal");
});
test("requires project membership — denies non-member access", async () => {
const t = newTest();
const _orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
const projectId = await createProject(t, ID_A, "auth");
// User B is NOT a member of org A / project.
await expect(
t
.withIdentity(identityB)
.query(api.signals.listLinkedSignalsForProject, { projectId })
).rejects.toThrow(/Project not found|Organization membership required/u);
});
test("does not return signals from a different project", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
// Two projects in the same org.
const projectId1 = await createProject(t, ID_A, "p1");
const projectId2 = await createProject(t, ID_A, "p2");
const issue1 = await createIssue(t, identityA, projectId1, "Issue P1");
const issue2 = await createIssue(t, identityA, projectId2, "Issue P2");
// Signal linked to issue in project2.
const sig = await createSignalInProject(
t,
identityA,
orgId,
projectId2,
"xproj",
problemStatement
);
await linkSignalToIssue(t, sig, issue2, orgId, projectId2);
// Querying project1 should NOT see project2's signal.
const result1 = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, {
projectId: projectId1,
});
expect(result1[String(issue1)] ?? []).toHaveLength(0);
// Querying project2 SHOULD see it.
const result2 = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, {
projectId: projectId2,
});
expect(result2[String(issue2)] ?? []).toHaveLength(1);
});
test("returns correct source count for multi-source signals", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "sources");
const issue = await createIssue(t, identityA, projectId, "Multi-source");
// Create a signal with two sources.
const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a");
const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b");
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
projectId,
processedBy,
});
await linkSignalToIssue(t, result.signalId, issue, orgId, projectId);
const linked = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
const signals = linked[String(issue)]!;
expect(signals).toHaveLength(1);
expect(signals[0]!.sourceCount).toBe(2);
});
test("returns empty record for a project with no attachments", async () => {
const t = newTest();
const _orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "empty");
await createIssue(t, identityA, projectId, "Orphan issue");
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(0);
});
test("issue with no linked signals is omitted from the result", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "mixed");
const issueWith = await createIssue(t, identityA, projectId, "Has signal");
const issueWithout = await createIssue(
t,
identityA,
projectId,
"No signal"
);
const sig = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"mixed1",
problemStatement
);
await linkSignalToIssue(t, sig, issueWith, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(1);
expect(result[String(issueWith)]).toHaveLength(1);
expect(result[String(issueWithout)]).toBeUndefined();
});
});

View File

@@ -282,6 +282,23 @@ export default defineSchema({
"messageId",
]),
// -----------------------------------------------------------------
// Signal-to-issue attachments. A proper relation that links one Signal
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple
// Signals may attach to the same ProjectIssue. The relation is scoped
// by organization and project to prevent cross-tenant leakage.
// -----------------------------------------------------------------
signalIssueAttachments: defineTable({
signalId: v.id("signals"),
issueId: v.id("projectIssues"),
organizationId: v.id("organizations"),
projectId: v.id("projects"),
createdAt: v.number(),
})
.index("by_signal", ["signalId"])
.index("by_issue", ["issueId"])
.index("by_signal_and_issue", ["signalId", "issueId"]),
// -----------------------------------------------------------------
// Flue persistence stores (schema/format version 4).
// -----------------------------------------------------------------

View File

@@ -0,0 +1,552 @@
import { env } from "@code/env/convex";
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const TOKEN = env.FLUE_DB_TOKEN;
const BAD_TOKEN = "not-the-right-token";
const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (t: TestConvex<typeof schema>) => {
const org = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as Id<"organizations">;
};
const problemStatement = {
title: "Deploy fails on staging",
summary: "The staging deploy command exits with a DNS resolution error.",
desiredOutcome: "Staging deploys successfully without DNS errors.",
constraints: ["Must not change the production pipeline"],
};
const seedMessage = async (
t: TestConvex<typeof schema>,
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: orgId,
rawText,
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: orgId,
submissionId: `sub-${clientRequestId}`,
});
return begun.messageId;
};
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
repoName: string
): Promise<Id<"projects">> => {
const ownerIdentity = { tokenIdentifier: ownerId };
await t
.withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: repoName,
repositoryPath: `owner/${repoName}`,
normalizedUrl: `https://github.com/owner/${repoName}`,
url: `https://github.com/owner/${repoName}`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: `# ${repoName}\n\nTest.\n`,
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
// Create a project-scoped signal via the routing backend.
const createRoutingSignal = async (
t: TestConvex<typeof schema>,
orgId: Id<"organizations">,
projectId: Id<"projects">,
messageIds: string[]
): Promise<Id<"signals">> => {
const result = await t.mutation(api.signalRouting.createSignal, {
messageIds,
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
return result.signalId as Id<"signals">;
};
describe("signalRouting authentication", () => {
test("invalid token is rejected on every function", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "auth-test");
const messageId = await seedMessage(t, orgId, "req-auth", "test message");
await expect(
t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listProjects, {
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
});
});
describe("signalRouting create and route", () => {
test("create signal, list evidence, and list signals", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "route-test");
const messageId = await seedMessage(
t,
orgId,
"req-route",
"The work-unit card should expose the generated PR."
);
// Evidence appears before signal creation.
const evidenceBefore = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidenceBefore).toHaveLength(1);
expect(evidenceBefore[0]?.rawText).toBe(
"The work-unit card should expose the generated PR."
);
// Create the signal.
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Evidence is consumed after signal creation.
const evidenceAfter = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidenceAfter).toHaveLength(0);
// Signal appears in the project list.
const signals = await t.query(api.signalRouting.listSignals, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(signals).toHaveLength(1);
expect(signals[0]?._id).toBe(signalId);
expect(signals[0]?.problemStatement.title).toBe("Deploy fails on staging");
});
test("attach signal to existing issue", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "attach-test");
const messageId = await seedMessage(t, orgId, "req-attach", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Create an issue via the existing projectIssues backend.
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Existing issue body that is long enough.",
projectId,
title: "Existing UI issue",
});
const result = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(result.alreadyAttached).toBe(false);
// List active issues includes the attached issue.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
expect(issues[0]?._id).toBe(issueId);
});
test("create issue from signal (attach-vs-create fallback)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "create-test");
const messageId = await seedMessage(t, orgId, "req-create", "new problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(result.created).toBe(true);
expect(result.projectId).toBe(projectId);
// The issue exists and is open.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
expect(issues[0]?.title).toBe("Deploy fails on staging");
});
});
describe("signalRouting idempotency", () => {
test("duplicate attach retry returns existing without duplicate event", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-attach");
const messageId = await seedMessage(t, orgId, "req-idem-att", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Existing issue body that is long enough for validation.",
projectId,
title: "Pre-existing issue",
});
// First attach.
const first = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(first.alreadyAttached).toBe(false);
// Retry the same attach.
const second = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(second.alreadyAttached).toBe(true);
expect(second.attachmentId).toBe(first.attachmentId);
// Verify only one signal.attached event was emitted (not duplicated).
const events = await t
.withIdentity(identityA)
.query(api.projectIssues.events, { projectId });
const attached = events.filter((e: { kind: string }) =>
e.kind.includes("signal.attached")
);
expect(attached).toHaveLength(1);
});
test("duplicate create-from-signal retry returns existing issue", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-create");
const messageId = await seedMessage(t, orgId, "req-idem-crt", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// First create.
const first = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(first.created).toBe(true);
// Retry — should return existing, not create a new issue.
const second = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(second.created).toBe(false);
expect(second.issueId).toBe(first.issueId);
// Only one issue exists.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
});
test("duplicate signal creation is idempotent (sourceKey)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-signal");
const messageId = await seedMessage(t, orgId, "req-idem-sig", "test");
const first = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
const second = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
expect(second.signalId).toBe(first.signalId);
});
});
describe("signalRouting cross-project rejection", () => {
test("attach rejects when signal and issue are in different projects", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectA = await createProject(t, ID_A, "cross-a");
const projectB = await createProject(t, ID_A, "cross-b");
const messageId = await seedMessage(t, orgId, "req-cross", "problem");
// Signal is scoped to project A.
const signalId = await createRoutingSignal(t, orgId, projectA, [messageId]);
// Issue is in project B.
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Issue in project B with enough body text.",
projectId: projectB,
title: "Project B issue",
});
await expect(
t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
})
).rejects.toThrow(/must belong to the same project/u);
});
test("attach rejects org-scoped signal (no projectId)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "org-scope-test");
const messageId = await seedMessage(t, orgId, "req-org", "problem");
// Create an org-scoped signal (no projectId).
const signalId = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
token: TOKEN,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Issue body that is long enough for the validator.",
projectId,
title: "Some issue",
});
await expect(
t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId.signalId as string,
token: TOKEN,
})
).rejects.toThrow(/not project-scoped/u);
});
});
describe("signalRouting provenance", () => {
test("exact message raw text is preserved through the routing loop", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "prov-test");
const rawText = " The work-unit card should expose the generated PR. ";
const messageId = await seedMessage(t, orgId, "req-prov", rawText);
// Evidence shows exact raw text.
const evidence = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidence[0]?.rawText).toBe(rawText);
// Signal creation preserves exact snapshot.
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Verify via the existing signals.get that the snapshot is exact.
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId });
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
});
});
describe("signalRouting begin issue", () => {
test("begin transitions an open issue to queued", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "begin-test");
const messageId = await seedMessage(t, orgId, "req-begin", "start this");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
const status = await t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: TOKEN,
});
expect(status).toBe("queued");
});
test("begin rejects invalid token", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "begin-auth");
const messageId = await seedMessage(t, orgId, "req-begin-auth", "msg");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
await expect(
t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
});
});
describe("signalRouting project context", () => {
test("get project context returns project and documents", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "ctx-test");
const context = await t.query(api.signalRouting.getProjectContext, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(context.project._id).toBe(projectId);
expect(context.project.name).toBe("ctx-test");
expect(context.contextDocuments.length).toBeGreaterThan(0);
});
test("list projects returns organization projects", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "list-proj-test");
const projects = await t.query(api.signalRouting.listProjects, {
organizationId: orgId,
token: TOKEN,
});
expect(projects).toHaveLength(1);
expect(projects[0]?._id).toBe(projectId);
});
});

View File

@@ -0,0 +1,721 @@
import { env } from "@code/env/convex";
import {
projectIssueDraftFromSignal,
queueProjectIssue,
type ProjectIssueDraft,
} from "@code/primitives/project-issue";
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import type { Doc, Id } from "./_generated/dataModel";
import {
mutation,
type MutationCtx,
query,
type QueryCtx,
} from "./_generated/server";
// ---------------------------------------------------------------------------
// Agent token guard (service-level auth for Zopu tools).
// ---------------------------------------------------------------------------
const requireAgent = (token: string) => {
if (token !== env.FLUE_DB_TOKEN) {
throw new ConvexError("Invalid agent control token");
}
};
const PROBLEM_STATEMENT = v.object({
title: v.string(),
summary: v.string(),
desiredOutcome: v.string(),
constraints: v.array(v.string()),
});
// ---------------------------------------------------------------------------
// View helpers
// ---------------------------------------------------------------------------
interface EvidenceMessage {
readonly messageId: string;
readonly rawText: string;
readonly createdAt: number;
readonly submissionId: string | null;
}
interface ActiveIssueView {
readonly _id: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly body: string;
readonly status: string;
readonly projectId: Id<"projects">;
readonly updatedAt: number;
}
interface ProjectSummaryView {
readonly _id: Id<"projects">;
readonly name: string;
readonly description: string | null;
readonly organizationId: Id<"organizations">;
}
interface ContextDocumentView {
readonly kind: string;
readonly path: string;
readonly content: string;
readonly revision: number;
}
interface SignalSummaryView {
readonly _id: Id<"signals">;
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
};
readonly projectId: Id<"projects"> | null;
readonly createdAt: number;
}
// ---------------------------------------------------------------------------
// Organization + project authorization (agent-gated, no user identity).
// The agent instance id IS the organization id; the tool never supplies
// tenancy. We verify the organization exists and the project belongs to it.
// ---------------------------------------------------------------------------
const authorizeProjectInOrg = async (
ctx: MutationCtx | QueryCtx,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<Doc<"projects">> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
if (project.organizationId !== organizationId) {
throw new ConvexError("Project does not belong to the target organization");
}
return project;
};
const authorizeSignalInOrg = async (
ctx: MutationCtx | QueryCtx,
organizationId: Id<"organizations">,
signalId: Id<"signals">
): Promise<Doc<"signals">> => {
const signal = await ctx.db.get(signalId);
if (!signal) {
throw new ConvexError("Signal not found");
}
if (signal.organizationId !== organizationId) {
throw new ConvexError("Signal does not belong to the target organization");
}
return signal;
};
// ---------------------------------------------------------------------------
// 1. List admitted user messages not yet consumed by a Signal.
// ---------------------------------------------------------------------------
export const listEvidence = query({
args: {
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args): Promise<EvidenceMessage[]> => {
requireAgent(args.token);
const conversationId = args.organizationId;
const admitted = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
const candidates = admitted.filter(
(message) => message.role === "user" && message.status === "admitted"
);
const unused: EvidenceMessage[] = [];
for (const message of candidates) {
const consumed = await ctx.db
.query("signalSources")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
.eq("messageId", message.messageId)
)
.first();
if (!consumed) {
unused.push({
createdAt: message.createdAt,
messageId: message.messageId,
rawText: message.rawText,
submissionId: message.submissionId ?? null,
});
}
}
return unused;
},
});
// ---------------------------------------------------------------------------
// 2. Create a Signal from selected messages + structured problem statement.
// ---------------------------------------------------------------------------
const buildSourceKey = (
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
const resolveSources = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): Promise<Doc<"conversationMessages">[]> => {
if (messageIds.length === 0) {
throw new ConvexError("At least one source message is required");
}
if (new Set(messageIds).size !== messageIds.length) {
throw new ConvexError("Source message ids must be unique");
}
const resolved: Doc<"conversationMessages">[] = [];
for (const messageId of messageIds) {
const doc = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", organizationId)
.eq("conversationId", conversationId)
.eq("messageId", messageId)
)
.unique();
if (!doc) {
throw new ConvexError(`Source message not found: ${messageId}`);
}
if (doc.role !== "user") {
throw new ConvexError(
`Source message is not a user message: ${messageId}`
);
}
if (doc.status !== "admitted") {
throw new ConvexError(`Source message is not admitted: ${messageId}`);
}
resolved.push(doc);
}
return resolved;
};
export const createSignal = mutation({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
messageIds: v.array(v.string()),
problemStatement: PROBLEM_STATEMENT,
processedByAgentInstanceId: v.string(),
token: v.string(),
},
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
requireAgent(args.token);
if (args.projectId) {
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
}
const conversationId = args.organizationId;
if (conversationId !== args.organizationId) {
throw new ConvexError("Conversation does not belong to organization");
}
const sourceKey = buildSourceKey(
args.organizationId,
conversationId,
args.messageIds
);
const existing = await ctx.db
.query("signals")
.withIndex("by_organization_and_sourceKey", (q) =>
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
)
.unique();
if (existing) {
return { signalId: existing._id };
}
const sources = await resolveSources(
ctx,
args.organizationId,
conversationId,
args.messageIds
);
const now = Date.now();
const prospectiveId = crypto.randomUUID();
const scope =
args.projectId === undefined
? { _tag: "Organization" as const, organizationId: args.organizationId }
: {
_tag: "Project" as const,
organizationId: args.organizationId,
projectId: args.projectId,
};
const signal = await Effect.runPromise(
composeSignal({
createdAt: now,
id: prospectiveId,
problemStatement: args.problemStatement,
processedBy: {
agentInstanceId: args.processedByAgentInstanceId,
agentName: "zopu",
},
scope,
sourceMessages: sources.map((doc) => ({
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
})),
})
).catch((error: unknown) => {
if (error instanceof SignalValidationError) {
throw new ConvexError(`Invalid signal: ${error.message}`);
}
throw new ConvexError(
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
);
});
const signalId = await ctx.db.insert("signals", {
conversationId,
createdAt: now,
organizationId: args.organizationId,
problemStatement: {
constraints: [...signal.problemStatement.constraints],
desiredOutcome: signal.problemStatement.desiredOutcome,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
},
processedByAgentInstanceId: args.processedByAgentInstanceId,
processedByAgentName: "zopu",
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
sourceKey,
});
await Promise.all(
sources.map((doc, ordinal) =>
ctx.db.insert("signalSources", {
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal,
organizationId: args.organizationId,
rawTextSnapshot: doc.rawText,
signalId,
...(doc.submissionId === undefined
? {}
: { submissionId: doc.submissionId }),
sourceCreatedAt: doc.createdAt,
})
)
);
return { signalId };
},
});
// ---------------------------------------------------------------------------
// 3. List recent Signals for a project (or organization-wide when no project).
// ---------------------------------------------------------------------------
export const listSignals = query({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
token: v.string(),
},
handler: async (ctx, args): Promise<SignalSummaryView[]> => {
requireAgent(args.token);
if (args.projectId) {
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
const signals = await ctx.db
.query("signals")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId as Id<"projects">)
)
.order("desc")
.take(20);
return signals.map((s) => ({
_id: s._id,
createdAt: s.createdAt,
problemStatement: s.problemStatement,
projectId: s.projectId ?? null,
}));
}
const signals = await ctx.db
.query("signals")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.order("desc")
.take(20);
return signals.map((s) => ({
_id: s._id,
createdAt: s.createdAt,
problemStatement: s.problemStatement,
projectId: s.projectId ?? null,
}));
},
});
// ---------------------------------------------------------------------------
// 4. List active (open/queued/working/needs-input) ProjectIssues for a project.
// ---------------------------------------------------------------------------
export const listActiveIssues = query({
args: {
organizationId: v.id("organizations"),
projectId: v.id("projects"),
token: v.string(),
},
handler: async (ctx, args): Promise<ActiveIssueView[]> => {
requireAgent(args.token);
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
const issues = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_status", (q) =>
q.eq("projectId", args.projectId)
)
.take(100);
return issues
.filter(
(issue) =>
issue.status === "open" ||
issue.status === "queued" ||
issue.status === "working" ||
issue.status === "needs-input"
)
.map((issue) => ({
_id: issue._id,
body: issue.body,
number: issue.number,
projectId: issue.projectId,
status: issue.status,
title: issue.title,
updatedAt: issue.updatedAt,
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
},
});
// ---------------------------------------------------------------------------
// 5. Attach a Signal to an existing ProjectIssue (idempotent).
// ---------------------------------------------------------------------------
export const attachSignalToIssue = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
issueId: v.id("projectIssues"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
attachmentId: Id<"signalIssueAttachments">;
alreadyAttached: boolean;
}> => {
requireAgent(args.token);
const signal = await authorizeSignalInOrg(
ctx,
args.organizationId,
args.signalId
);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
// Cross-project rejection: the signal must be project-scoped and its
// project must match the issue's project. Organization equality alone is
// insufficient — it would permit attaching across different projects in
// the same org.
if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
if (signal.projectId !== issue.projectId) {
throw new ConvexError("Signal and issue must belong to the same project");
}
// Idempotent: if the attachment already exists, return it without
// emitting a duplicate event.
const existing = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_signal_and_issue", (q) =>
q.eq("signalId", args.signalId).eq("issueId", args.issueId)
)
.unique();
if (existing) {
return {
alreadyAttached: true,
attachmentId: existing._id,
};
}
const now = Date.now();
const attachmentId = await ctx.db.insert("signalIssueAttachments", {
createdAt: now,
issueId: args.issueId,
organizationId: args.organizationId,
projectId: issue.projectId,
signalId: args.signalId,
});
await ctx.db.insert("projectEvents", {
createdAt: now,
data: {
signalProblemTitle: signal.problemStatement.title,
signalId: String(signal._id),
},
issueId: args.issueId,
kind: "signal.attached",
projectId: issue.projectId,
});
return { alreadyAttached: false, attachmentId };
},
});
// ---------------------------------------------------------------------------
// 6. Create a new ProjectIssue from a Signal.
// ---------------------------------------------------------------------------
const insertIssue = async (
ctx: MutationCtx,
projectId: Id<"projects">,
draft: ProjectIssueDraft
): Promise<Id<"projectIssues">> => {
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body: draft.body,
createdAt: timestamp,
number,
projectId,
status: "open",
title: draft.title,
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, source: "signal", title: draft.title },
issueId,
kind: "issue.created",
projectId,
});
return issueId;
};
export const createIssueFromSignal = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
created: boolean;
issueId: Id<"projectIssues">;
projectId: Id<"projects">;
}> => {
requireAgent(args.token);
const signal = await authorizeSignalInOrg(
ctx,
args.organizationId,
args.signalId
);
if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
await authorizeProjectInOrg(ctx, args.organizationId, signal.projectId);
// Idempotent: if any attachment already exists for this signal, return
// the existing issue without creating a duplicate. This prevents Flue
// retries from producing duplicate issues/attachments.
const existingAttachment = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_signal", (q) => q.eq("signalId", args.signalId))
.first();
if (existingAttachment) {
return {
created: false,
issueId: existingAttachment.issueId,
projectId: existingAttachment.projectId,
};
}
const draft = await Effect.runPromise(
projectIssueDraftFromSignal({
problemStatement: signal.problemStatement,
signalId: String(signal._id),
})
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid project issue"
);
});
const issueId = await insertIssue(ctx, signal.projectId, draft);
// Auto-attach the signal to the new issue.
const now = Date.now();
await ctx.db.insert("signalIssueAttachments", {
createdAt: now,
issueId,
organizationId: args.organizationId,
projectId: signal.projectId,
signalId: signal._id,
});
return { created: true, issueId, projectId: signal.projectId };
},
});
// ---------------------------------------------------------------------------
// 7. Begin a ProjectIssue (transition to queued/working).
// ---------------------------------------------------------------------------
export const beginIssue = mutation({
args: {
organizationId: v.id("organizations"),
issueId: v.id("projectIssues"),
token: v.string(),
},
handler: async (ctx, args): Promise<"queued" | "working"> => {
requireAgent(args.token);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
const status = await Effect.runPromise(
queueProjectIssue(issue.status)
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid issue status"
);
});
if (status === issue.status) {
return status;
}
const timestamp = Date.now();
await ctx.db.patch(issue._id, {
status,
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number: issue.number },
issueId: issue._id,
kind: "issue.queued",
projectId: issue.projectId,
});
return status;
},
});
// ---------------------------------------------------------------------------
// 8. Get project summary + context documents for routing decisions.
// ---------------------------------------------------------------------------
export const getProjectContext = query({
args: {
organizationId: v.id("organizations"),
projectId: v.id("projects"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
project: ProjectSummaryView;
contextDocuments: ContextDocumentView[];
}> => {
requireAgent(args.token);
const project = await authorizeProjectInOrg(
ctx,
args.organizationId,
args.projectId
);
const docs = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.take(25);
return {
contextDocuments: docs.map((doc) => ({
content: doc.content,
kind: doc.kind,
path: doc.path,
revision: doc.revision,
})),
project: {
_id: project._id,
description: project.description ?? null,
name: project.name,
organizationId: project.organizationId,
},
};
},
});
// ---------------------------------------------------------------------------
// 9. List projects in the organization (for selecting a project context).
// ---------------------------------------------------------------------------
export const listProjects = query({
args: {
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args): Promise<ProjectSummaryView[]> => {
requireAgent(args.token);
const projects = await ctx.db
.query("projects")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.take(50);
return projects.map((p) => ({
_id: p._id,
description: p.description ?? null,
name: p.name,
organizationId: p.organizationId,
}));
},
});

View File

@@ -412,6 +412,69 @@ describe("signals", () => {
expect(list).toHaveLength(2);
});
test("reversed selection preserves agent ordinals and original timestamps", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first");
const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second");
// Chronological selection: m1 then m2.
const chrono = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
processedBy,
});
// Reversed selection: m2 then m1 (non-chronological agent order).
const reversed = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m2, m1],
organizationId: orgId,
problemStatement,
processedBy,
});
expect(reversed.signalId).not.toBe(chrono.signalId);
const chronoSignal = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: chrono.signalId });
const reversedSignal = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: reversed.signalId });
// Chronological: ordinals follow selection.
expect(chronoSignal?.sources[0]?.messageId).toBe(m1);
expect(chronoSignal?.sources[0]?.ordinal).toBe(0);
expect(chronoSignal?.sources[1]?.messageId).toBe(m2);
expect(chronoSignal?.sources[1]?.ordinal).toBe(1);
// Reversed: ordinals follow the agent's reversed selection, and each
// source retains its original creation timestamp (unchanged by reordering).
expect(reversedSignal?.sources[0]?.messageId).toBe(m2);
expect(reversedSignal?.sources[0]?.ordinal).toBe(0);
expect(reversedSignal?.sources[1]?.messageId).toBe(m1);
expect(reversedSignal?.sources[1]?.ordinal).toBe(1);
// Exact raw text is preserved in both orderings.
expect(
chronoSignal?.sources.map(
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
)
).toEqual(["first", "second"]);
expect(
reversedSignal?.sources.map(
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
)
).toEqual(["second", "first"]);
});
test("creates a project-scoped signal when the project owner is an org member", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);

View File

@@ -9,7 +9,7 @@ import {
type QueryCtx,
query,
} from "./_generated/server";
import { requireOrganizationMember } from "./authz";
import { requireOrganizationMember, requireProjectMember } from "./authz";
const PROBLEM_STATEMENT = v.object({
title: v.string(),
@@ -435,3 +435,71 @@ export const get = query({
};
},
});
// ---------------------------------------------------------------------------
// Linked Signal query for the Work OS surface.
// ---------------------------------------------------------------------------
export interface LinkedSignalView {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
/**
* List all Signal-issue attachments for a project, grouped by issue id.
* User-authenticated: the caller must be a member of the project organization.
* Used by the Work OS card list to show per-issue linked Signal counts.
*/
export const listLinkedSignalsForProject = query({
args: {
projectId: v.id("projects"),
},
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
const issues = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.take(100);
const result: Record<string, LinkedSignalView[]> = {};
for (const issue of issues) {
const attachments = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.collect();
if (attachments.length === 0) {
continue;
}
const signals = await Promise.all(
attachments.map(async (attachment) => {
const signal = await ctx.db.get(attachment.signalId);
if (!signal || signal.organizationId !== organizationId) {
return null;
}
const sources = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) =>
q.eq("signalId", signal._id)
)
.collect();
return {
createdAt: signal.createdAt,
signalId: signal._id,
sourceCount: sources.length,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
} satisfies LinkedSignalView;
})
);
result[String(issue._id)] = signals
.filter((entry): entry is LinkedSignalView => entry !== null)
// Deterministic order: newest linked Signal first.
.toSorted((a, b) => b.createdAt - a.createdAt);
}
return result;
},
});

View File

@@ -223,11 +223,22 @@ describe("composeSignal", () => {
getValidationError(input, "MixedConversation");
});
it("rejects decreasing source timestamps", () => {
it("accepts non-chronological source order with preserved timestamps", () => {
const input = makeSignalInput();
input.sourceMessages[1].createdAt = 99;
// Agent selects the later message first (logical, not chronological order).
const [earlier, later] = input.sourceMessages;
input.sourceMessages = [later, earlier];
getValidationError(input, "OutOfOrderSource");
const signal = runSignal(input);
// Ordinal follows the agent's selection, not timestamps.
expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
"message-2",
"message-1",
]);
// Original timestamps are preserved exactly on each source.
const timestamps = signal.sourceMessages.map(({ createdAt }) => createdAt);
expect(timestamps).toEqual([200, 100]);
});
it("preserves source order and exact raw whitespace", () => {

View File

@@ -92,7 +92,6 @@ export const SignalValidationReason = Schema.Literals([
"InvalidInput",
"DuplicateSource",
"MixedConversation",
"OutOfOrderSource",
]);
export type SignalValidationReason = typeof SignalValidationReason.Type;
@@ -118,9 +117,11 @@ export const composeSignal = Effect.fn("Signal.compose")(
)
);
const [{ conversationId, createdAt: firstCreatedAt }] =
signal.sourceMessages;
let previousCreatedAt = firstCreatedAt;
// Source order follows the agent's logical selection, not chronological
// timestamp order. Each source retains its original createdAt so temporal
// provenance is preserved exactly; the ordinal array position reflects the
// agent's narrative composition of the problem statement.
const [{ conversationId }] = signal.sourceMessages;
const messageIds = new Set<ConversationMessageId>();
for (const source of signal.sourceMessages) {
@@ -142,16 +143,6 @@ export const composeSignal = Effect.fn("Signal.compose")(
);
}
messageIds.add(source.messageId);
if (source.createdAt < previousCreatedAt) {
return yield* Effect.fail(
new SignalValidationError({
message: "Signal source timestamps must be non-decreasing",
reason: "OutOfOrderSource",
})
);
}
previousCreatedAt = source.createdAt;
}
return signal;

View File

@@ -42,37 +42,37 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0.003 90);
--foreground: oklch(0.93 0.002 90);
--card: oklch(0.17 0.004 90);
--card-foreground: oklch(0.93 0.002 90);
--popover: oklch(0.17 0.004 90);
--popover-foreground: oklch(0.93 0.002 90);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--primary-foreground: oklch(0.17 0.004 90);
--secondary: oklch(0.22 0.004 90);
--secondary-foreground: oklch(0.93 0.002 90);
--muted: oklch(0.22 0.004 90);
--muted-foreground: oklch(0.62 0.004 90);
--accent: oklch(0.27 0.005 90);
--accent-foreground: oklch(0.93 0.002 90);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.28 0.004 90);
--input: oklch(0.22 0.004 90);
--ring: oklch(0.5 0.004 90);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.17 0.004 90);
--sidebar-foreground: oklch(0.93 0.002 90);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-primary-foreground: oklch(0.93 0.002 90);
--sidebar-accent: oklch(0.22 0.004 90);
--sidebar-accent-foreground: oklch(0.93 0.002 90);
--sidebar-border: oklch(0.28 0.004 90);
--sidebar-ring: oklch(0.5 0.004 90);
}
@theme inline {