Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ export type ChatAgent = {
/** The address of the agent's currently live run, or null when none is
* live (mid-redeploy). */
readonly liveAddress: string | null;
/** The newest deployment's status for this agent's asset — `undefined`
* when it has never been deployed. `"pending"`/`"recovering"` mean a run
* is on the way up; a terminal status (`released`, `failed`,
* `destroy_failed`, `stopped`) means nothing is running and nothing is
* coming unless someone restarts it. */
readonly latestStatus: string | undefined;
};

const LIVE_DEPLOYMENT_STATUSES = new Set(["deployed", "pending", "recovering"]);
/** A deployment status that means a run is on the way up but not live yet
* — distinct from a terminal status, which means nothing is running. */
export const STARTING_DEPLOYMENT_STATUSES = new Set(["pending", "recovering"]);

const DeploymentsSchema = WorkflowDeploymentResponse.array();
const WorkflowAssetSchema = type({ id: "string", name: "string" }).array();
Expand Down Expand Up @@ -121,17 +130,22 @@ export async function listChatAgents(tenantId: string): Promise<readonly ChatAge
const addressByRunId = new Map(runs.map((run) => [run.id, run.address]));
const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name]));

const byAsset = new Map<string, { addresses: Set<string>; liveAddress: string | null }>();
const byAsset = new Map<
string,
{ addresses: Set<string>; liveAddress: string | null; latestStatus: string | undefined }
>();
for (const deployment of deployments) {
const address = addressByRunId.get(deployment.id);
if (address === undefined || address.length === 0) continue;
const entry = byAsset.get(deployment.definitionAssetId) ?? {
addresses: new Set<string>(),
liveAddress: null,
latestStatus: undefined,
};
entry.addresses.add(address);
// Deployments come back newest-first, so the first live one seen per
// asset is the current one.
// Deployments come back newest-first, so the first one seen per asset
// is the latest, and the first live one seen is the current one.
if (entry.latestStatus === undefined) entry.latestStatus = deployment.status;
if (entry.liveAddress === null && LIVE_DEPLOYMENT_STATUSES.has(deployment.status)) {
entry.liveAddress = address;
}
Expand All @@ -146,10 +160,20 @@ export async function listChatAgents(tenantId: string): Promise<readonly ChatAge
assetName,
addresses: [...entry.addresses],
liveAddress: entry.liveAddress,
latestStatus: entry.latestStatus,
};
});
}

/** True once an agent's latest deployment has gone terminal (or it has
* never been deployed) — nothing is running and nothing is coming up on
* its own; a restart is the only way forward. False while a run is live or
* still on its way up (`pending`/`recovering`). */
export function isAgentNotRunning(agent: Pick<ChatAgent, "liveAddress" | "latestStatus">): boolean {
if (agent.liveAddress !== null) return false;
return agent.latestStatus === undefined || !STARTING_DEPLOYMENT_STATUSES.has(agent.latestStatus);
}

/** The agent an `@name` first message picks, matched case-insensitively
* against the agent's display name with spaces removed. */
export function agentFromMention(
Expand Down
75 changes: 63 additions & 12 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,45 @@ import {
TableRow,
} from "@corbits/react-ui";
import { Robot } from "@/lib/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { QueryView } from "@/lib/api-query";
import { chatKeys, chatPath } from "../chat-path";
import { listChatAgents, type ChatAgent } from "@/chat/threads-api";
import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api";
import { redeployRoomAgent } from "../workbench-create";
import { useBench } from "../bench-context";
import { Link } from "../navigation";
import { useTenantQuery } from "../routines-api";
import { StageTopBar } from "../shell/stage-top-bar";

/**
* One agent's roster row: `Live` once it has a live run address, `Starting`
* while a deploy is still landing one (mid-first-deploy or mid-redeploy).
* One agent's roster row: `Live` once it has a live run address, `starting`
* while a deploy is still landing one (mid-first-deploy or mid-redeploy),
* `not-running` once its latest deployment has gone terminal (a hub
* restart releases every prior allocation) — a restart is then the only
* way forward.
*/
export function agentRosterStatus(agent: Pick<ChatAgent, "liveAddress">): "live" | "starting" {
return agent.liveAddress === null ? "starting" : "live";
export function agentRosterStatus(
agent: Pick<ChatAgent, "liveAddress" | "latestStatus">,
): "live" | "starting" | "not-running" {
if (agent.liveAddress !== null) return "live";
return isAgentNotRunning(agent) ? "not-running" : "starting";
}

export function AgentsRosterList({ agents }: { readonly agents: readonly ChatAgent[] }) {
export function AgentsRosterList({
tenantId,
agents,
}: {
readonly tenantId: string;
readonly agents: readonly ChatAgent[];
}) {
const queryClient = useQueryClient();
const restart = useMutation({
mutationFn: (agent: ChatAgent) => redeployRoomAgent(tenantId, agent),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) });
},
});
if (agents.length === 0) {
return (
<RichEmptyState
Expand Down Expand Up @@ -68,15 +89,42 @@ export function AgentsRosterList({ agents }: { readonly agents: readonly ChatAge
{status === "live" ? (
<StatusDot label="Live" live tone="emphasis" size="xs" />
) : null}
<Badge tone={status === "live" ? "success" : "neutral"} className="normal-case">
{status === "live" ? "Live" : "Starting"}
<Badge
tone={
status === "live"
? "success"
: status === "not-running"
? "warning"
: "neutral"
}
className="normal-case"
>
{status === "live"
? "Live"
: restart.isPending && restart.variables?.id === agent.id
? "Starting…"
: status === "not-running"
? "Not running"
: "Starting"}
</Badge>
</span>
</TableCell>
<TableCell>
<Button asChild variant="outline" size="sm">
<Link to={chatPath(agent.id)}>Chat</Link>
</Button>
<span className="inline-flex items-center gap-2">
{status === "not-running" ? (
<Button
variant="outline"
size="sm"
disabled={restart.isPending}
onClick={() => restart.mutate(agent)}
>
Restart
</Button>
) : null}
<Button asChild variant="outline" size="sm">
<Link to={chatPath(agent.id)}>Chat</Link>
</Button>
</span>
</TableCell>
</TableRow>
);
Expand All @@ -92,6 +140,9 @@ export function AgentsRoute() {
chatKeys.agents(selectedTenantId ?? "none"),
selectedTenantId !== null,
() => listChatAgents(selectedTenantId as string),
// Keep polling while any agent is not live, so a released→deployed
// (or a restart landing) transition is seen without a reload.
(agents) => ((agents?.some((agent) => agent.liveAddress === null) ?? false) ? 3000 : false),
);

return (
Expand All @@ -111,7 +162,7 @@ export function AgentsRoute() {
) : (
<div className="px-4 pb-5 sm:px-7">
<QueryView query={agentsQuery} label="your agents" skeleton="rows">
{(agents) => <AgentsRosterList agents={agents} />}
{(agents) => <AgentsRosterList tenantId={selectedTenantId} agents={agents} />}
</QueryView>
</div>
)}
Expand Down
62 changes: 58 additions & 4 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Markdown } from "@/chat/markdown";
import { MessageAttachments } from "@/chat/message-attachments";
import {
agentFromMention,
isAgentNotRunning,
listChatAgents,
markChatSeen,
readChat,
Expand All @@ -23,6 +24,7 @@ import {
type ChatAgent,
} from "@/chat/threads-api";
import { MYRA_SOURCE_CONFIG } from "../myra-source";
import { redeployRoomAgent } from "../workbench-create";
import { useBench } from "../bench-context";
import { chatIdFromPath, chatKeys, chatPath, NEW_CHAT_PATH } from "../chat-path";
import { usePendingApprovals } from "../pending-approvals";
Expand Down Expand Up @@ -66,10 +68,19 @@ function NewChat({
},
});

const restart = useMutation({
mutationFn: (agent: ChatAgent) => redeployRoomAgent(tenantId, agent),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) });
},
});

const defaultAgent = agents.find((agent) => agent.name === MYRA_SOURCE_CONFIG.displayName);
const chosen = agents.find((agent) => agent.id === selected) ?? defaultAgent ?? agents[0];
const error: unknown = start.error ?? agentsQuery.error;
const isLive = chosen?.liveAddress !== null && chosen?.liveAddress !== undefined;
const notRunning = chosen !== undefined && isAgentNotRunning(chosen);
const restarting = restart.isPending && restart.variables?.id === chosen?.id;

return (
<PageShell width="prose" className="page-fill">
Expand All @@ -96,11 +107,26 @@ function NewChat({
{error === null || error === undefined ? null : (
<p className="chat-thread-error">{errorText(error)}</p>
)}
{restart.error === null || restart.error === undefined ? null : (
<p className="chat-thread-error">{errorText(restart.error)}</p>
)}
{notRunning && !restarting ? (
<Button
variant="outline"
size="sm"
disabled={restart.isPending}
onClick={() => chosen !== undefined && restart.mutate(chosen)}
>
Restart {chosen?.name}
</Button>
) : null}
<Composer
placeholder={
isLive
? `Message ${chosen?.name ?? MYRA_SOURCE_CONFIG.displayName}`
: `${chosen?.name ?? MYRA_SOURCE_CONFIG.displayName} is starting…`
: notRunning
? `${chosen?.name ?? MYRA_SOURCE_CONFIG.displayName} is not running`
: `${chosen?.name ?? MYRA_SOURCE_CONFIG.displayName} is starting…`
}
busy={start.isPending}
disabled={!isLive}
Expand Down Expand Up @@ -171,6 +197,17 @@ function ChatTranscript({
onSuccess: () => queryClient.invalidateQueries({ queryKey: chatKeys.scope(tenantId) }),
});

const restart = useMutation({
mutationFn: () => {
if (chat === undefined) throw new Error("no agent to restart");
return redeployRoomAgent(tenantId, chat.agent);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) });
void queryClient.invalidateQueries({ queryKey: chatKeys.one(tenantId, chatId) });
},
});

if (chatQuery.isError && chat === undefined) {
return (
<PageShell width="full" className="page-fill">
Expand Down Expand Up @@ -217,11 +254,28 @@ function ChatTranscript({
</ul>
)}
{reply.error === null ? null : <p className="chat-thread-error">{errorText(reply.error)}</p>}
{restart.error === null ? null : (
<p className="chat-thread-error">{errorText(restart.error)}</p>
)}
{isAgentNotRunning(chat.agent) && !restart.isPending ? (
<Button
variant="outline"
size="sm"
disabled={restart.isPending}
onClick={() => restart.mutate()}
>
Restart {chat.agentName}
</Button>
) : null}
<Composer
placeholder={
chat.agent.liveAddress === null
? `${chat.agentName} is starting…`
: `Message ${chat.agentName}`
chat.agent.liveAddress !== null
? `Message ${chat.agentName}`
: restart.isPending
? `${chat.agentName} is starting…`
: isAgentNotRunning(chat.agent)
? `${chat.agentName} is not running`
: `${chat.agentName} is starting…`
}
busy={reply.isPending}
disabled={chat.agent.liveAddress === null}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export function useTenantQuery<T>(
key: readonly unknown[],
enabled: boolean,
fetcher: () => Promise<T>,
refetchInterval?: (data: T | undefined) => number | false,
): APIQuery<T> {
const result = useQuery({
queryKey: key,
Expand All @@ -152,6 +153,12 @@ export function useTenantQuery<T>(
throw cause;
}
},
...(refetchInterval !== undefined
? {
refetchInterval: (query: { state: { data: T | undefined } }) =>
refetchInterval(query.state.data),
}
: {}),
});
return toAPIQuery(result);
}
Loading