loops: repo picker + agent/team/org staffing + sidebar edit/delete
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 12s
ci / frontend (push) Successful in 25s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Adds the missing pieces the wizard needed and the sidebar controls
around it:

- LoopsWizard is now a 6-step flow (identity → repo → task/topology
  → triggers → repeat → assign agents) plus the existing secrets
  card. ResearchWizard picks up the same repo step and a hard gate
  when the workspace has zero agents.
- New LoopStaffingStep with three tabs — Individual / Team /
  Organization — that mix freely per loop; selections persist via
  new loop_agents / loop_teams / loop_orgs join tables (0035
  migration), each cascading on loop_id so hard-delete stays a
  single-row DELETE.
- Backend CreateLoopRequest / UpdateLoopRequest accept the three
  lists and apply_staffing does a transactional replace-all;
  list_loops / get_loop hydrate the lists via a flattened
  LoopWithStaffing response.
- LoopsList sidebar gains per-row enable/disable, edit (reopens the
  wizard prefilled with the current loop, PATCHes on submit), and
  delete with an inline confirm.
- NoAgentsGate blocks launching a loop or research topic from a
  workspace with no roster; the sidebar `+` buttons also disable
  with a tooltip pointing at the TEAM tier.

Not yet wired: the run driver still fills role slots from the
workspace-wide pool; teaching enqueue_iteration to prefer
loop_agents/loop_teams/loop_orgs is a follow-up.
This commit is contained in:
Omar Sobh
2026-07-07 22:09:06 -07:00
parent 2562541f5b
commit a6da19430f
25 changed files with 1781 additions and 156 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_orgs WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "01cdcd2bbc04ebfefb5ba2a69bee88ddb732a88117f63841bfd17d07a4b969af"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO loop_teams (loop_id, team_id) VALUES ($1, $2)\n ON CONFLICT (loop_id, team_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "19f7b8608588160f1d76dd7c01ba7ad628e1ee32961a5717ae22002a243a869c"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT agent_id, role_slot FROM loop_agents WHERE loop_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "role_slot",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "309f684f9ec2dcfb5b178140a1ac0a4669c470bd1389805f458063cd66db9a89"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_agents WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3f74f01c6411409c49ad66edba8a059ca8a72daae3570a6e99d59de7c3b6dc82"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO loop_agents (loop_id, agent_id, role_slot)\n VALUES ($1, $2, $3)\n ON CONFLICT (loop_id, agent_id) DO UPDATE\n SET role_slot = EXCLUDED.role_slot",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "3fa15bc7add3b658ae2f9dbbd7b95780542899119a5f11f5fd250a1cf656fc93"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT tokens_in, tokens_out, credits FROM usage_events\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tokens_in",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "tokens_out",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "credits",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "47c1cee8591250819d22e87058c6011bebb77eaf93c1cfa2535db42221d8ecf3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT org_id FROM loop_orgs WHERE loop_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "org_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "78647b9a8d7fbcf9789bc1873eb354a5379f6170758327eaa1f147564af90e98"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_teams WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "c73f821ea00857bcc06add6478a4f999bbf8bdecb96e9fb79cdeafd1d22a2a2f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO loop_orgs (loop_id, org_id) VALUES ($1, $2)\n ON CONFLICT (loop_id, org_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "cef235c554c7cc30968e7b9d717442e01d1226c16c7fd85daff2aec97728955a"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO topology_runs\n (id, workspace_id, task, kind, status, graph, tier, research_topic_id)\n VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Jsonb",
"Uuid"
]
},
"nullable": []
},
"hash": "ebeb5ef13a40b1693425588096a4ec37dd01ecf6cd9c051aaf37399a69f617bf"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT team_id FROM loop_teams WHERE loop_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "team_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "ed832a03e4abe417e1cec7997f35c0e3b337d691fcc6899dc98696fa1fcb6448"
}
Generated
+1
View File
@@ -956,6 +956,7 @@ dependencies = [
"cm-topology", "cm-topology",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"getrandom 0.2.17",
"hex", "hex",
"hmac", "hmac",
"jsonwebtoken", "jsonwebtoken",
+78 -8
View File
@@ -38,11 +38,24 @@ pub struct CreateLoopRequest {
/// {kind: 'infinite' | 'iters' | 'until', n?: int} /// {kind: 'infinite' | 'iters' | 'until', n?: int}
#[serde(default = "default_repeat")] #[serde(default = "default_repeat")]
pub repeat_policy: Value, pub repeat_policy: Value,
#[serde(default)]
pub agents: Vec<AgentSlotInput>,
#[serde(default)]
pub teams: Vec<Uuid>,
#[serde(default)]
pub orgs: Vec<Uuid>,
} }
fn default_repeat() -> Value { fn default_repeat() -> Value {
serde_json::json!({"kind": "infinite"}) serde_json::json!({"kind": "infinite"})
} }
#[derive(Deserialize)]
pub struct AgentSlotInput {
pub agent_id: Uuid,
#[serde(default)]
pub role_slot: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct LoopCreated { pub struct LoopCreated {
pub id: Uuid, pub id: Uuid,
@@ -131,6 +144,8 @@ pub async fn create_loop(
) )
.await?; .await?;
apply_staffing(&state.pool, id, &body.agents, &body.teams, &body.orgs).await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(LoopCreated { Json(LoopCreated {
@@ -141,24 +156,72 @@ pub async fn create_loop(
)) ))
} }
async fn apply_staffing(
pool: &sqlx::PgPool,
loop_id: Uuid,
agents: &[AgentSlotInput],
teams: &[Uuid],
orgs: &[Uuid],
) -> Result<(), ApiError> {
let slots: Vec<cm_db::repo::loops::AgentSlot> = agents
.iter()
.map(|a| cm_db::repo::loops::AgentSlot {
agent_id: a.agent_id,
role_slot: a.role_slot.clone(),
})
.collect();
cm_db::repo::loops::set_agents(pool, loop_id, &slots).await?;
cm_db::repo::loops::set_teams(pool, loop_id, teams).await?;
cm_db::repo::loops::set_orgs(pool, loop_id, orgs).await?;
Ok(())
}
#[derive(Serialize)]
pub struct LoopWithStaffing {
#[serde(flatten)]
pub inner: cm_db::repo::loops::Loop,
pub agents: Vec<cm_db::repo::loops::AgentSlot>,
pub teams: Vec<Uuid>,
pub orgs: Vec<Uuid>,
}
async fn hydrate_staffing(
pool: &sqlx::PgPool,
inner: cm_db::repo::loops::Loop,
) -> Result<LoopWithStaffing, ApiError> {
let id = inner.id;
let agents = cm_db::repo::loops::agents(pool, id).await?;
let teams = cm_db::repo::loops::teams(pool, id).await?;
let orgs = cm_db::repo::loops::orgs(pool, id).await?;
Ok(LoopWithStaffing {
inner,
agents,
teams,
orgs,
})
}
pub async fn list_loops( pub async fn list_loops(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
) -> Result<Json<Vec<cm_db::repo::loops::Loop>>, ApiError> { ) -> Result<Json<Vec<LoopWithStaffing>>, ApiError> {
Ok(Json( let loops = cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?;
cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?, let mut out = Vec::with_capacity(loops.len());
)) for l in loops {
out.push(hydrate_staffing(&state.pool, l).await?);
}
Ok(Json(out))
} }
pub async fn get_loop( pub async fn get_loop(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<cm_db::repo::loops::Loop>, ApiError> { ) -> Result<Json<LoopWithStaffing>, ApiError> {
cm_db::repo::loops::get(&state.pool, id, user.workspace_id.as_uuid()) let inner = cm_db::repo::loops::get(&state.pool, id, user.workspace_id.as_uuid())
.await? .await?
.map(Json) .ok_or(ApiError::NotFound)?;
.ok_or(ApiError::NotFound) Ok(Json(hydrate_staffing(&state.pool, inner).await?))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -169,6 +232,12 @@ pub struct UpdateLoopRequest {
pub task_template: String, pub task_template: String,
pub triggers: Value, pub triggers: Value,
pub repeat_policy: Value, pub repeat_policy: Value,
#[serde(default)]
pub agents: Vec<AgentSlotInput>,
#[serde(default)]
pub teams: Vec<Uuid>,
#[serde(default)]
pub orgs: Vec<Uuid>,
} }
pub async fn patch_loop( pub async fn patch_loop(
@@ -199,6 +268,7 @@ pub async fn patch_loop(
}, },
) )
.await?; .await?;
apply_staffing(&state.pool, id, &body.agents, &body.teams, &body.orgs).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+114
View File
@@ -221,6 +221,120 @@ pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), D
Ok(()) Ok(())
} }
// --- Staffing ---------------------------------------------------------------
//
// Loops attach agents, teams, and/or orgs. The three join tables are
// parallel; a loop can mix modes (e.g. one team + a couple of specialist
// agents). Callers use the `set_*` replace-all shape so PATCH is a single
// transactional swap — simpler than diffing and cheap for the list sizes
// this UI generates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSlot {
pub agent_id: Uuid,
pub role_slot: Option<String>,
}
pub async fn set_agents(
pool: &PgPool,
loop_id: Uuid,
slots: &[AgentSlot],
) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_agents WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for s in slots {
sqlx::query!(
"INSERT INTO loop_agents (loop_id, agent_id, role_slot)
VALUES ($1, $2, $3)
ON CONFLICT (loop_id, agent_id) DO UPDATE
SET role_slot = EXCLUDED.role_slot",
loop_id,
s.agent_id,
s.role_slot,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn agents(pool: &PgPool, loop_id: Uuid) -> Result<Vec<AgentSlot>, DbError> {
let rows = sqlx::query!(
"SELECT agent_id, role_slot FROM loop_agents WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| AgentSlot {
agent_id: r.agent_id,
role_slot: r.role_slot,
})
.collect())
}
pub async fn set_teams(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_teams WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for id in ids {
sqlx::query!(
"INSERT INTO loop_teams (loop_id, team_id) VALUES ($1, $2)
ON CONFLICT (loop_id, team_id) DO NOTHING",
loop_id,
id,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn teams(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT team_id FROM loop_teams WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.team_id).collect())
}
pub async fn set_orgs(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
let mut tx = pool.begin().await?;
sqlx::query!("DELETE FROM loop_orgs WHERE loop_id = $1", loop_id)
.execute(&mut *tx)
.await?;
for id in ids {
sqlx::query!(
"INSERT INTO loop_orgs (loop_id, org_id) VALUES ($1, $2)
ON CONFLICT (loop_id, org_id) DO NOTHING",
loop_id,
id,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn orgs(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT org_id FROM loop_orgs WHERE loop_id = $1",
loop_id,
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.org_id).collect())
}
/// Loops the scheduler tick should fire NOW. Only reads what the enqueue /// Loops the scheduler tick should fire NOW. Only reads what the enqueue
/// path needs, so the tick stays cheap even when the workspace has hundreds /// path needs, so the tick stays cheap even when the workspace has hundreds
/// of loops. /// of loops.
@@ -631,6 +631,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
/> />
) : isLoops ? ( ) : isLoops ? (
<LoopsList <LoopsList
agents={claws}
selectedId={loopsSel} selectedId={loopsSel}
onSelect={setLoopsSel} onSelect={setLoopsSel}
refreshKey={loopsRefresh} refreshKey={loopsRefresh}
@@ -0,0 +1,356 @@
"use client";
// The "Assign agents" step for the loops wizard. Three tabs:
// Individual — pick specific agents (with optional role slots)
// Team — pick one or more teams; the run driver expands each team
// to its members at fire time
// Org — pick one or more orgs; expanded transitively
//
// The three selections aren't mutually exclusive — you can mix a team with a
// couple of specialist agents. Backend stores them in loop_agents /
// loop_teams / loop_orgs and merges at run time.
import { useEffect, useMemo, useState } from "react";
import { Building2, User, Users } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import { fetchOrgs, fetchTeams, type GroupSummary } from "@/lib/api/structure";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface AgentSelection {
agent_id: string;
role_slot: string;
}
export function LoopStaffingStep({
agents,
selectedAgents,
onAgentsChange,
selectedTeamIds,
onTeamsChange,
selectedOrgIds,
onOrgsChange,
}: {
agents: Agent[];
selectedAgents: AgentSelection[];
onAgentsChange: (v: AgentSelection[]) => void;
selectedTeamIds: string[];
onTeamsChange: (v: string[]) => void;
selectedOrgIds: string[];
onOrgsChange: (v: string[]) => void;
}) {
const [mode, setMode] = useState<"individual" | "team" | "org">("individual");
const [teams, setTeams] = useState<GroupSummary[] | null>(null);
const [orgs, setOrgs] = useState<GroupSummary[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [t, o] = await Promise.all([fetchTeams(), fetchOrgs()]);
if (cancelled) return;
setTeams(t);
setOrgs(o);
} catch (e) {
if (!cancelled)
setLoadError(e instanceof Error ? e.message : "load failed");
}
})();
return () => {
cancelled = true;
};
}, []);
const totalPicked =
selectedAgents.length + selectedTeamIds.length + selectedOrgIds.length;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={labelStyle}>Assign agents</span>
<span style={hintStyle}>
{totalPicked === 0
? "None picked yet"
: `${totalPicked} selection${totalPicked === 1 ? "" : "s"}`}
</span>
</div>
<p style={hintStyle}>
Mix and match: pick individual agents, entire teams, or whole
organizations. At fire time the run driver expands teams and orgs
into their members and merges with any explicit agents you added.
</p>
<div style={pillRowStyle}>
<ModePill
active={mode === "individual"}
onClick={() => setMode("individual")}
icon={<User aria-hidden size={12} />}
label="Individual"
count={selectedAgents.length}
/>
<ModePill
active={mode === "team"}
onClick={() => setMode("team")}
icon={<Users aria-hidden size={12} />}
label="Team"
count={selectedTeamIds.length}
/>
<ModePill
active={mode === "org"}
onClick={() => setMode("org")}
icon={<Building2 aria-hidden size={12} />}
label="Organization"
count={selectedOrgIds.length}
/>
</div>
{mode === "individual" && (
<AgentList
agents={agents}
selected={selectedAgents}
onChange={onAgentsChange}
/>
)}
{mode === "team" && (
<GroupList
items={teams}
selectedIds={selectedTeamIds}
onChange={onTeamsChange}
empty="No teams in this workspace yet — create one from the TEAMS tier first."
/>
)}
{mode === "org" && (
<GroupList
items={orgs}
selectedIds={selectedOrgIds}
onChange={onOrgsChange}
empty="No organizations in this workspace yet — create one from the ORGS tier first."
/>
)}
{loadError && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{loadError}</p>
)}
</div>
);
}
function AgentList({
agents,
selected,
onChange,
}: {
agents: Agent[];
selected: AgentSelection[];
onChange: (v: AgentSelection[]) => void;
}) {
if (agents.length === 0) {
return <p style={hintStyle}>No agents in this workspace yet.</p>;
}
return (
<div style={listStyle}>
{agents.map((a) => {
const chosen = selected.find((s) => s.agent_id === a.id);
return (
<div
key={a.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${chosen ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: chosen ? "rgba(255,111,97,.06)" : "transparent",
}}
>
<input
type="checkbox"
checked={!!chosen}
onChange={(e) => {
if (e.target.checked)
onChange([...selected, { agent_id: a.id, role_slot: "" }]);
else onChange(selected.filter((s) => s.agent_id !== a.id));
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{a.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{a.job_title || "—"}
</div>
</div>
{chosen && (
<input
value={chosen.role_slot}
onChange={(e) =>
onChange(
selected.map((s) =>
s.agent_id === a.id
? { ...s, role_slot: e.target.value }
: s,
),
)
}
placeholder="role slot (optional)"
style={{ ...fieldStyle, width: 180, padding: "6px 10px" }}
/>
)}
</div>
);
})}
</div>
);
}
function GroupList({
items,
selectedIds,
onChange,
empty,
}: {
items: GroupSummary[] | null;
selectedIds: string[];
onChange: (v: string[]) => void;
empty: string;
}) {
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
if (items === null) return <p style={hintStyle}>Loading…</p>;
if (items.length === 0) return <p style={hintStyle}>{empty}</p>;
return (
<div style={listStyle}>
{items.map((g) => {
const on = selectedSet.has(g.id);
return (
<label
key={g.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: on ? "rgba(255,111,97,.06)" : "transparent",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={on}
onChange={(e) => {
if (e.target.checked) onChange([...selectedIds, g.id]);
else onChange(selectedIds.filter((x) => x !== g.id));
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{g.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{g.kind} · {g.status}
</div>
</div>
</label>
);
})}
</div>
);
}
function ModePill({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
count: number;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "6px 12px",
borderRadius: 6,
background: active ? "rgba(255,138,122,.12)" : "transparent",
color: active ? "#ffbfb3" : "#8a8a92",
fontSize: 11,
fontFamily: mono,
fontWeight: 700,
letterSpacing: ".08em",
textTransform: "uppercase",
border: 0,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{icon}
{label}
{count > 0 && (
<span
style={{
padding: "1px 6px",
borderRadius: 5,
background: active ? "rgba(255,138,122,.2)" : "rgba(255,255,255,.06)",
fontSize: 10,
}}
>
{count}
</span>
)}
</button>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#b5b5bd",
};
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.5,
};
const fieldStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#0a0a0c",
color: "#f3f3f5",
outline: "none",
fontSize: 13,
};
const pillRowStyle: React.CSSProperties = {
display: "inline-flex",
gap: 2,
padding: 2,
borderRadius: 8,
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.06)",
alignSelf: "flex-start",
};
const listStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
maxHeight: 320,
overflow: "auto",
paddingRight: 2,
};
+245 -16
View File
@@ -1,11 +1,27 @@
"use client"; "use client";
// Sidebar for the Loops tier — real loop cards + `+` opens the wizard. // Sidebar for the Loops tier — real loop cards + `+` opens the wizard.
// Each row has an inline action row: enable/disable · edit · delete
// (with confirm). Mutations bump a local counter so the list refetches
// without waiting on a parent refresh.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus } from "lucide-react"; import {
Pause,
Pencil,
Play,
Plus,
Trash2,
} from "lucide-react";
import { listLoops, type Loop } from "@/lib/api/loops"; import {
deleteLoop,
disableLoop,
enableLoop,
listLoops,
type Loop,
} from "@/lib/api/loops";
import type { Agent } from "@/lib/api/schemas";
import { LoopsWizard } from "./LoopsWizard"; import { LoopsWizard } from "./LoopsWizard";
@@ -13,19 +29,27 @@ const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsList({ export function LoopsList({
agents,
selectedId, selectedId,
onSelect, onSelect,
refreshKey, refreshKey,
onCreated, onCreated,
}: { }: {
agents: Agent[];
selectedId: string | null; selectedId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
refreshKey: number; refreshKey: number;
onCreated: (id: string) => void; onCreated: (id: string) => void;
}) { }) {
const noAgents = agents.length === 0;
const [loops, setLoops] = useState<Loop[]>([]); const [loops, setLoops] = useState<Loop[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [editing, setEditing] = useState<Loop | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localBump, setLocalBump] = useState(0);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -44,7 +68,38 @@ export function LoopsList({
return () => { return () => {
alive = false; alive = false;
}; };
}, [refreshKey]); }, [refreshKey, localBump]);
async function toggle(l: Loop) {
if (busyId) return;
setBusyId(l.id);
setError(null);
try {
if (l.enabled) await disableLoop(l.id);
else await enableLoop(l.id);
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "toggle failed");
} finally {
setBusyId(null);
}
}
async function doDelete(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await deleteLoop(id);
setConfirmDeleteId(null);
if (selectedId === id) onSelect(""); // clear selection
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setBusyId(null);
}
}
return ( return (
<> <>
@@ -83,17 +138,20 @@ export function LoopsList({
<button <button
type="button" type="button"
onClick={() => setWizardOpen(true)} onClick={() => setWizardOpen(true)}
title="New loop" disabled={noAgents}
title={noAgents ? "Add an agent to your roster first" : "New loop"}
aria-label="New loop" aria-label="New loop"
style={{ style={{
flex: "none", flex: "none",
width: 34, width: 34,
height: 34, height: 34,
borderRadius: 9, borderRadius: 9,
border: "1px dashed rgba(255,111,97,.4)", border: `1px dashed ${noAgents ? "rgba(255,255,255,.14)" : "rgba(255,111,97,.4)"}`,
background: "rgba(255,111,97,.06)", background: noAgents
color: "#ff6f61", ? "rgba(255,255,255,.02)"
cursor: "pointer", : "rgba(255,111,97,.06)",
color: noAgents ? "#5a5a62" : "#ff6f61",
cursor: noAgents ? "not-allowed" : "pointer",
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -132,25 +190,32 @@ export function LoopsList({
loops.map((l) => { loops.map((l) => {
const on = l.id === selectedId; const on = l.id === selectedId;
const dot = l.enabled ? "#5fd08a" : "#5a5a62"; const dot = l.enabled ? "#5fd08a" : "#5a5a62";
const confirming = confirmDeleteId === l.id;
return ( return (
<button <div
key={l.id} key={l.id}
type="button"
onClick={() => onSelect(l.id)}
style={{ style={{
width: "100%",
textAlign: "left",
padding: "10px 12px", padding: "10px 12px",
marginBottom: 4, marginBottom: 4,
borderRadius: 9, borderRadius: 9,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`, border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
background: on ? "rgba(255,111,97,.08)" : "#101014", background: on ? "rgba(255,111,97,.08)" : "#101014",
color: "#eaeaee",
cursor: "pointer",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 6, gap: 6,
}} }}
>
<button
type="button"
onClick={() => onSelect(l.id)}
style={{
all: "unset",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 4,
}}
> >
<div <div
style={{ style={{
@@ -181,7 +246,9 @@ export function LoopsList({
background: dot, background: dot,
}} }}
/> />
<span style={{ color: dot }}>{l.enabled ? "enabled" : "disabled"}</span> <span style={{ color: dot }}>
{l.enabled ? "enabled" : "disabled"}
</span>
<span style={{ opacity: 0.5 }}>·</span> <span style={{ opacity: 0.5 }}>·</span>
<span> <span>
{l.next_fire_at {l.next_fire_at
@@ -190,12 +257,102 @@ export function LoopsList({
</span> </span>
</div> </div>
</button> </button>
{confirming ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 4,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
flex: 1,
}}
>
Delete this loop? Historical runs stay.
</span>
<button
type="button"
onClick={() => doDelete(l.id)}
disabled={busyId === l.id}
style={{
...dangerBtn,
opacity: busyId === l.id ? 0.6 : 1,
}}
>
{busyId === l.id ? "…" : "Delete"}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
style={ghostBtn}
>
Cancel
</button>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 2,
}}
>
<RowIconBtn
title={l.enabled ? "Disable loop" : "Enable loop"}
onClick={() => toggle(l)}
disabled={busyId === l.id}
>
{l.enabled ? (
<Pause aria-hidden size={12} />
) : (
<Play aria-hidden size={12} />
)}
</RowIconBtn>
<RowIconBtn
title="Edit loop"
onClick={() => setEditing(l)}
disabled={busyId === l.id}
>
<Pencil aria-hidden size={12} />
</RowIconBtn>
<RowIconBtn
title="Delete loop"
onClick={() => setConfirmDeleteId(l.id)}
disabled={busyId === l.id}
danger
>
<Trash2 aria-hidden size={12} />
</RowIconBtn>
</div>
)}
</div>
); );
}) })
)} )}
{error ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#ff8a7a",
padding: "8px 12px",
}}
>
{error}
</p>
) : null}
</div> </div>
{wizardOpen && ( {wizardOpen && (
<LoopsWizard <LoopsWizard
agentsCount={agents.length}
onClose={() => setWizardOpen(false)} onClose={() => setWizardOpen(false)}
onCreated={(id) => { onCreated={(id) => {
setWizardOpen(false); setWizardOpen(false);
@@ -203,6 +360,78 @@ export function LoopsList({
}} }}
/> />
)} )}
{editing && (
<LoopsWizard
initial={editing}
agentsCount={agents.length}
onClose={() => setEditing(null)}
onCreated={() => setEditing(null)}
onSaved={() => {
setEditing(null);
setLocalBump((n) => n + 1);
}}
/>
)}
</> </>
); );
} }
function RowIconBtn({
title,
onClick,
disabled,
danger,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
disabled={disabled}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: danger ? "#ff8a7a" : "#cfcfd5",
cursor: disabled ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: disabled ? 0.5 : 1,
}}
>
{children}
</button>
);
}
const dangerBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};
+190 -32
View File
@@ -13,6 +13,8 @@ import { X } from "lucide-react";
import { import {
createLoop, createLoop,
patchLoop,
type Loop,
type LoopCreated, type LoopCreated,
type LoopRepeatPolicy, type LoopRepeatPolicy,
} from "@/lib/api/loops"; } from "@/lib/api/loops";
@@ -20,23 +22,61 @@ import {
// bearer.ts + @clerk/nextjs/server into the client bundle and breaks the // bearer.ts + @clerk/nextjs/server into the client bundle and breaks the
// production build. Call /api/topologies with plain fetch instead. // production build. Call /api/topologies with plain fetch instead.
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology"; import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
import { fetchClaws } from "@/lib/api/team";
import type { Agent } from "@/lib/api/schemas";
import {
LoopStaffingStep,
type AgentSelection,
} from "./LoopStaffingStep";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsWizard({ export function LoopsWizard({
initial,
agentsCount,
onClose, onClose,
onCreated, onCreated,
onSaved,
}: { }: {
/** When set, the wizard runs in edit mode — pre-fills state and PATCHes on submit. */
initial?: Loop;
/** Override for the roster gate. When omitted the wizard fetches /api/team/claws itself. */
agentsCount?: number;
onClose: () => void; onClose: () => void;
onCreated: (id: string) => void; onCreated: (id: string) => void;
onSaved?: (id: string) => void;
}) { }) {
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1); const editing = !!initial;
const [title, setTitle] = useState(""); const [rosterCount, setRosterCount] = useState<number | null>(
const [description, setDescription] = useState(""); agentsCount ?? null,
const [task, setTask] = useState(""); );
const [rosterAgents, setRosterAgents] = useState<Agent[]>([]);
const [selectedAgents, setSelectedAgents] = useState<AgentSelection[]>(
initial?.agents?.map((a) => ({
agent_id: a.agent_id,
role_slot: a.role_slot ?? "",
})) ?? [],
);
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>(
initial?.teams ?? [],
);
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>(
initial?.orgs ?? [],
);
const initialRepeat = readInitialRepeat(initial?.repeat_policy);
const initialTriggers = readInitialTriggers(initial?.triggers);
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5 | 6 | 7>(1);
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [task, setTask] = useState(initial?.task_template ?? "");
// When editing an existing loop, start in advanced mode so the caller sees
// the exact graph they saved. Builder mode would rebuild + overwrite it.
const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">( const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">(
"builder", editing ? "advanced" : "builder",
); );
const [catalog, setCatalog] = useState<CatalogEntry[]>([]); const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const [kind, setKind] = useState<string>(""); const [kind, setKind] = useState<string>("");
@@ -44,18 +84,26 @@ export function LoopsWizard({
const [builtGraph, setBuiltGraph] = useState<TopologyGraph | null>(null); const [builtGraph, setBuiltGraph] = useState<TopologyGraph | null>(null);
const [buildingGraph, setBuildingGraph] = useState(false); const [buildingGraph, setBuildingGraph] = useState(false);
const [graphText, setGraphText] = useState( const [graphText, setGraphText] = useState(
JSON.stringify({ nodes: [], edges: [] }, null, 2), initial
? JSON.stringify(initial.graph, null, 2)
: JSON.stringify({ nodes: [], edges: [] }, null, 2),
);
const [cronEnabled, setCronEnabled] = useState(
initialTriggers.cron !== undefined,
);
const [cron, setCron] = useState(initialTriggers.cron ?? "0 */6 * * *");
const [onCompletion, setOnCompletion] = useState(
!!initialTriggers.on_completion,
);
const [webhookEnabled, setWebhookEnabled] = useState(
!!initialTriggers.webhook_enabled,
); );
const [cronEnabled, setCronEnabled] = useState(false);
const [cron, setCron] = useState("0 */6 * * *");
const [onCompletion, setOnCompletion] = useState(false);
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [repeatKind, setRepeatKind] = useState<"infinite" | "iters" | "until">( const [repeatKind, setRepeatKind] = useState<"infinite" | "iters" | "until">(
"infinite", initialRepeat.kind,
); );
const [iters, setIters] = useState(10); const [iters, setIters] = useState(initialRepeat.iters);
const [untilEvent, setUntilEvent] = useState("ok"); const [untilEvent, setUntilEvent] = useState(initialRepeat.untilEvent);
const [untilWithin, setUntilWithin] = useState(20); const [untilWithin, setUntilWithin] = useState(initialRepeat.untilWithin);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [buildError, setBuildError] = useState<string | null>(null); const [buildError, setBuildError] = useState<string | null>(null);
@@ -81,6 +129,25 @@ export function LoopsWizard({
}; };
}, []); }, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const claws = await fetchClaws();
if (cancelled) return;
setRosterAgents(claws);
if (agentsCount === undefined) setRosterCount(claws.length);
} catch {
if (!cancelled && agentsCount === undefined) setRosterCount(0);
}
})();
return () => {
cancelled = true;
};
}, [agentsCount]);
const noAgents = rosterCount !== null && rosterCount === 0;
const selectedEntry = useMemo( const selectedEntry = useMemo(
() => catalog.find((c) => c.kind === kind), () => catalog.find((c) => c.kind === kind),
[catalog, kind], [catalog, kind],
@@ -135,9 +202,11 @@ export function LoopsWizard({
: isValidJson(graphText); : isValidJson(graphText);
const canNext = const canNext =
(step === 1 && title.trim().length > 0) || (step === 1 && title.trim().length > 0) ||
(step === 2 && task.trim().length > 0 && graphReady) || step === 2 ||
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) || (step === 3 && task.trim().length > 0 && graphReady) ||
step === 4; (step === 4 && (cronEnabled || onCompletion || webhookEnabled)) ||
step === 5 ||
step === 6;
async function submit() { async function submit() {
setSubmitError(null); setSubmitError(null);
@@ -169,7 +238,15 @@ export function LoopsWizard({
within_iters: Math.max(1, Math.floor(untilWithin)), within_iters: Math.max(1, Math.floor(untilWithin)),
}; };
} }
const out = await createLoop({ const staffing = {
agents: selectedAgents.map((s) => ({
agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined,
})),
teams: selectedTeamIds,
orgs: selectedOrgIds,
};
const body = {
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
graph, graph,
@@ -180,13 +257,21 @@ export function LoopsWizard({
...(webhookEnabled ? { webhook_enabled: true } : {}), ...(webhookEnabled ? { webhook_enabled: true } : {}),
}, },
repeat_policy, repeat_policy,
}); ...(repo ? { repo } : {}),
...staffing,
};
if (editing && initial) {
await patchLoop(initial.id, body);
onSaved?.(initial.id);
return;
}
const out = await createLoop(body);
setCreated(out); setCreated(out);
// If no webhook material, dismiss immediately. // If no webhook material, dismiss immediately.
if (!out.webhook_token) { if (!out.webhook_token) {
onCreated(out.id); onCreated(out.id);
} else { } else {
setStep(5); setStep(7);
} }
} catch (e) { } catch (e) {
setSubmitError(e instanceof Error ? e.message : "create failed"); setSubmitError(e instanceof Error ? e.message : "create failed");
@@ -233,10 +318,10 @@ export function LoopsWizard({
}} }}
> >
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}> <span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
{step <= 4 ? `STEP ${step} / 4` : "SECRETS"} {step <= 6 ? `STEP ${step} / 6` : "SECRETS"}
</span> </span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}> <span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
{step <= 4 ? "New loop" : "Copy the webhook secret"} {step <= 6 ? (editing ? "Edit loop" : "New loop") : "Copy the webhook secret"}
</span> </span>
<button <button
type="button" type="button"
@@ -249,7 +334,10 @@ export function LoopsWizard({
</div> </div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && ( {noAgents ? (
<NoAgentsGate what="loop" onDismiss={onClose} />
) : null}
{!noAgents && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-title">Title</label> <label style={labelStyle} htmlFor="loop-title">Title</label>
<input <input
@@ -270,7 +358,19 @@ export function LoopsWizard({
</div> </div>
)} )}
{step === 2 && ( {!noAgents && step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}>
Pick a connected repo the loop should target. Task templates
and agents can reference {"{{repo.owner}}/{{repo.name}}"} once
a repo is chosen.
</p>
<RepoPicker value={repo} onChange={setRepo} optional />
</div>
)}
{!noAgents && step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label> <label style={labelStyle} htmlFor="loop-task">Task template</label>
<textarea <textarea
@@ -327,7 +427,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{step === 3 && ( {!noAgents && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Triggers (pick any combination)</span> <span style={labelStyle}>Triggers (pick any combination)</span>
<TriggerToggle <TriggerToggle
@@ -360,7 +460,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{step === 4 && ( {!noAgents && step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Repeat policy</span> <span style={labelStyle}>Repeat policy</span>
<label style={radioRowStyle(repeatKind === "infinite")}> <label style={radioRowStyle(repeatKind === "infinite")}>
@@ -467,7 +567,19 @@ export function LoopsWizard({
</div> </div>
)} )}
{step === 5 && created && ( {!noAgents && step === 6 && (
<LoopStaffingStep
agents={rosterAgents}
selectedAgents={selectedAgents}
onAgentsChange={setSelectedAgents}
selectedTeamIds={selectedTeamIds}
onTeamsChange={setSelectedTeamIds}
selectedOrgIds={selectedOrgIds}
onOrgsChange={setSelectedOrgIds}
/>
)}
{!noAgents && step === 7 && created && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}> <p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}>
Save these now — they&apos;re never shown again. To rotate, disable Save these now — they&apos;re never shown again. To rotate, disable
@@ -484,6 +596,7 @@ export function LoopsWizard({
)} )}
</div> </div>
{!noAgents && (
<div <div
style={{ style={{
padding: 14, padding: 14,
@@ -493,20 +606,20 @@ export function LoopsWizard({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
{step <= 4 ? ( {step <= 6 ? (
<> <>
<button <button
type="button" type="button"
onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5))} onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5 | 6 | 7))}
disabled={step === 1} disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }} style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
> >
Back Back
</button> </button>
{step < 4 ? ( {step < 6 ? (
<button <button
type="button" type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))} onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7))}
disabled={!canNext} disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }} style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
> >
@@ -519,7 +632,13 @@ export function LoopsWizard({
disabled={submitting} disabled={submitting}
style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }} style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }}
> >
{submitting ? "Creating…" : "Create loop"} {submitting
? editing
? "Saving…"
: "Creating…"
: editing
? "Save changes"
: "Create loop"}
</button> </button>
)} )}
</> </>
@@ -536,6 +655,7 @@ export function LoopsWizard({
</> </>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
); );
@@ -856,6 +976,44 @@ function staffRoles(
return roles; return roles;
} }
function readInitialTriggers(t: unknown): {
cron?: string;
on_completion?: boolean;
webhook_enabled?: boolean;
} {
if (!t || typeof t !== "object") return {};
const o = t as Record<string, unknown>;
return {
cron: typeof o.cron === "string" ? o.cron : undefined,
on_completion: !!o.on_completion,
webhook_enabled: !!o.webhook_enabled,
};
}
function readInitialRepeat(p: unknown): {
kind: "infinite" | "iters" | "until";
iters: number;
untilEvent: string;
untilWithin: number;
} {
const fallback = {
kind: "infinite" as const,
iters: 10,
untilEvent: "ok",
untilWithin: 20,
};
if (!p || typeof p !== "object") return fallback;
const o = p as Record<string, unknown>;
const kind = o.kind === "iters" || o.kind === "until" ? o.kind : "infinite";
return {
kind,
iters: typeof o.n === "number" ? o.n : fallback.iters,
untilEvent: typeof o.event === "string" ? o.event : fallback.untilEvent,
untilWithin:
typeof o.within_iters === "number" ? o.within_iters : fallback.untilWithin,
};
}
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
fontFamily: mono, fontFamily: mono,
fontSize: 11, fontSize: 11,
@@ -0,0 +1,92 @@
"use client";
// Reusable "no agents" gate. Loops + Research + scheduled sessions all
// need at least one agent in the roster to launch — this component
// renders the empty-state we show inside a wizard shell (or inline)
// when the workspace has zero agents.
import { Users } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function NoAgentsGate({
what,
onDismiss,
}: {
/** Human noun for the thing being blocked: "loop", "research topic", "scheduled session". */
what: string;
onDismiss?: () => void;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 14,
padding: "32px 24px",
textAlign: "center",
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: "rgba(255,111,97,.08)",
border: "1px solid rgba(255,111,97,.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#ff8a7a",
}}
>
<Users aria-hidden size={20} />
</div>
<div style={{ maxWidth: 380 }}>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "#f3f3f5",
marginBottom: 6,
}}
>
You need at least one agent to launch a {what}.
</div>
<p
style={{
fontFamily: mono,
fontSize: 12,
lineHeight: 1.6,
color: "#8a8a92",
}}
>
Add an agent to your roster first — individually, or by spinning up
a team or an organization from the TEAM tier. A {what} can&apos;t be
scheduled, staffed, or bound to a node pool until at least one
agent exists.
</p>
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
style={{
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
}}
>
Take me to the roster
</button>
) : null}
</div>
);
}
@@ -0,0 +1,397 @@
"use client";
// Shared repo picker used by the Loop + Research wizards. Fetches the
// workspace's already-connected repos from /api/repos (+ /api/repos/connections
// for the provider label) and lets the user pick one — or skip when optional.
//
// Emits a compact PickedRepo the caller can send in its create body; the
// backend can persist the id later without another round-trip.
import { useEffect, useMemo, useState } from "react";
import { GitBranch, Search, X } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface PickedRepo {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
interface RepoSummary {
id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
private: boolean;
description: string | null;
default_branch: string | null;
stars: number;
forks: number;
updated_at: string | null;
}
interface ConnectionSummary {
id: string;
provider: string;
owner: string | null;
base_url?: string | null;
label?: string;
}
export function RepoPicker({
value,
onChange,
optional = false,
emptyHint,
}: {
value: PickedRepo | null;
onChange: (v: PickedRepo | null) => void;
optional?: boolean;
emptyHint?: string;
}) {
const [repos, setRepos] = useState<RepoSummary[] | null>(null);
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [rRes, cRes] = await Promise.all([
fetch("/api/repos"),
fetch("/api/repos/connections"),
]);
if (cancelled) return;
if (rRes.ok) setRepos(await rRes.json());
else setRepos([]);
if (cRes.ok) setConnections(await cRes.json());
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "load failed");
setRepos([]);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const connLabel = useMemo(() => {
const m = new Map<string, string>();
for (const c of connections) {
m.set(c.id, c.label || c.owner || c.provider);
}
return m;
}, [connections]);
const filtered = useMemo(() => {
if (!repos) return [];
const q = query.trim().toLowerCase();
if (!q) return repos;
return repos.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.owner.toLowerCase().includes(q) ||
(r.description ?? "").toLowerCase().includes(q),
);
}, [repos, query]);
const grouped = useMemo(() => groupByOwner(filtered), [filtered]);
if (repos === null) {
return (
<p style={hintStyle}>Loading repositories…</p>
);
}
if (repos.length === 0) {
return (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px dashed rgba(255,255,255,.14)",
background: "rgba(255,255,255,.02)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontFamily: mono, fontSize: 12, color: "#cfcfd5" }}>
No repositories connected yet.
</div>
<div style={hintStyle}>
{emptyHint ??
"Connect a GitHub/Gitea provider from the REPOS tier first, then come back."}
</div>
{optional ? (
<button
type="button"
onClick={() => onChange(null)}
style={{ ...secondaryBtn, alignSelf: "flex-start" }}
>
Continue without a repo
</button>
) : null}
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{value && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,111,97,.5)",
background: "rgba(255,111,97,.08)",
}}
>
<GitBranch aria-hidden size={14} style={{ color: "#ffbfb3" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: mono,
fontSize: 12,
color: "#f3f3f5",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value.owner}/{value.name}
</div>
<div style={hintStyle}>
{value.provider}
{value.default_branch ? ` · ${value.default_branch}` : ""}
{connLabel.get(value.connection_id)
? ` · ${connLabel.get(value.connection_id)}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onChange(null)}
aria-label="Clear selection"
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X aria-hidden size={12} />
</button>
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#0a0a0c",
}}
>
<Search aria-hidden size={13} style={{ color: "#8a8a92" }} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by owner, name, or description…"
style={{
flex: 1,
border: 0,
outline: "none",
background: "transparent",
color: "#f3f3f5",
fontSize: 13,
}}
/>
{optional ? (
<button
type="button"
onClick={() => onChange(null)}
style={{
...secondaryBtn,
padding: "4px 10px",
fontSize: 11,
}}
>
None
</button>
) : null}
</div>
<div
style={{
maxHeight: 320,
overflow: "auto",
border: "1px solid rgba(255,255,255,.06)",
borderRadius: 10,
background: "rgba(255,255,255,.02)",
}}
>
{grouped.length === 0 ? (
<p style={{ ...hintStyle, padding: 12 }}>No repos match the filter.</p>
) : (
grouped.map(([owner, list]) => (
<div key={owner}>
<div
style={{
padding: "6px 12px",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".1em",
textTransform: "uppercase",
color: "#8a8a92",
background: "rgba(255,255,255,.03)",
borderBottom: "1px solid rgba(255,255,255,.05)",
position: "sticky",
top: 0,
}}
>
{owner}
</div>
{list.map((r) => {
const active = value?.repo_id === r.id;
return (
<button
key={r.id}
type="button"
onClick={() =>
onChange({
repo_id: r.id,
connection_id: r.connection_id,
provider: r.provider,
owner: r.owner,
name: r.name,
default_branch: r.default_branch,
})
}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
textAlign: "left",
padding: "8px 12px",
background: active
? "rgba(255,138,122,.08)"
: "transparent",
border: 0,
borderBottom: "1px solid rgba(255,255,255,.04)",
cursor: "pointer",
color: "#eaeaee",
}}
>
<GitBranch
aria-hidden
size={13}
style={{ color: active ? "#ffbfb3" : "#5a5a62" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: active ? 700 : 500,
color: "#f3f3f5",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.name}
</div>
{r.description ? (
<div
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.description}
</div>
) : null}
</div>
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#5a5a62",
flexShrink: 0,
}}
>
{r.default_branch ?? "—"}
</span>
</button>
);
})}
</div>
))
)}
</div>
{error && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)}
</div>
);
}
function groupByOwner(repos: RepoSummary[]): [string, RepoSummary[]][] {
const map = new Map<string, RepoSummary[]>();
for (const r of repos) {
const key = r.owner || "(unknown)";
const bucket = map.get(key) ?? [];
bucket.push(r);
map.set(key, bucket);
}
const out = Array.from(map.entries());
out.sort(([a], [b]) => a.localeCompare(b));
for (const [, arr] of out) arr.sort((x, y) => x.name.localeCompare(y.name));
return out;
}
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.5,
};
const secondaryBtn: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
@@ -99,17 +99,25 @@ export function ResearchList({
<button <button
type="button" type="button"
onClick={() => setWizardOpen(true)} onClick={() => setWizardOpen(true)}
title="New research topic" disabled={agents.length === 0}
title={
agents.length === 0
? "Add an agent to your roster first"
: "New research topic"
}
aria-label="New research topic" aria-label="New research topic"
style={{ style={{
flex: "none", flex: "none",
width: 34, width: 34,
height: 34, height: 34,
borderRadius: 9, borderRadius: 9,
border: "1px dashed rgba(255,111,97,.4)", border: `1px dashed ${agents.length === 0 ? "rgba(255,255,255,.14)" : "rgba(255,111,97,.4)"}`,
background: "rgba(255,111,97,.06)", background:
color: "#ff6f61", agents.length === 0
cursor: "pointer", ? "rgba(255,255,255,.02)"
: "rgba(255,111,97,.06)",
color: agents.length === 0 ? "#5a5a62" : "#ff6f61",
cursor: agents.length === 0 ? "not-allowed" : "pointer",
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -15,6 +15,8 @@ import {
wizardRefine, wizardRefine,
type OutcomeKind, type OutcomeKind,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -48,8 +50,9 @@ export function ResearchWizard({
onClose: () => void; onClose: () => void;
onCreated: (topicId: string) => void; onCreated: (topicId: string) => void;
}) { }) {
const [step, setStep] = useState<1 | 2 | 3 | 4>(1); const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec"); const [outcome, setOutcome] = useState<OutcomeKind>("spec");
const [refining, setRefining] = useState(false); const [refining, setRefining] = useState(false);
const [refineError, setRefineError] = useState<string | null>(null); const [refineError, setRefineError] = useState<string | null>(null);
@@ -67,6 +70,7 @@ export function ResearchWizard({
prompt, prompt,
outcome_kind: outcome, outcome_kind: outcome,
prior_description: prior, prior_description: prior,
...(repo ? { repo } : {}),
}); });
setTitle(out.suggested_title); setTitle(out.suggested_title);
setDescription(out.description); setDescription(out.description);
@@ -89,6 +93,7 @@ export function ResearchWizard({
agent_id: s.agent_id, agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined, role_slot: s.role_slot.trim() || undefined,
})), })),
...(repo ? { repo } : {}),
}); });
onCreated(id); onCreated(id);
} catch (e) { } catch (e) {
@@ -100,9 +105,10 @@ export function ResearchWizard({
const canNext = const canNext =
(step === 1 && prompt.trim().length > 0) || (step === 1 && prompt.trim().length > 0) ||
(step === 2 && title.trim().length > 0 && description.trim().length > 0) || step === 2 ||
step === 3 || (step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
step === 4; step === 4 ||
step === 5;
return ( return (
<div <div
@@ -143,7 +149,7 @@ export function ResearchWizard({
}} }}
> >
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}> <span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
STEP {step} / 4 STEP {step} / 5
</span> </span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}> <span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
New research topic New research topic
@@ -171,7 +177,10 @@ export function ResearchWizard({
{/* Body */} {/* Body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && ( {agents.length === 0 ? (
<NoAgentsGate what="research topic" onDismiss={onClose} />
) : null}
{agents.length > 0 && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}> <label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}>
Topic prompt Topic prompt
@@ -191,7 +200,18 @@ export function ResearchWizard({
</div> </div>
)} )}
{step === 2 && ( {agents.length > 0 && step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}>
Anchor the topic to a connected repo — the refine step will
use it as context, and downstream outcomes can cite it.
</p>
<RepoPicker value={repo} onChange={setRepo} optional />
</div>
)}
{agents.length > 0 && step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{!title && !refining ? ( {!title && !refining ? (
<button <button
@@ -247,7 +267,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{step === 3 && ( {agents.length > 0 && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span> <span style={labelStyle}>Outcome kind</span>
{OUTCOMES.map((o) => ( {OUTCOMES.map((o) => (
@@ -279,7 +299,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{step === 4 && ( {agents.length > 0 && step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Assign agents</span> <span style={labelStyle}>Assign agents</span>
<p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p> <p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p>
@@ -346,6 +366,7 @@ export function ResearchWizard({
</div> </div>
{/* Footer */} {/* Footer */}
{agents.length > 0 && (
<div <div
style={{ style={{
padding: 14, padding: 14,
@@ -357,16 +378,16 @@ export function ResearchWizard({
> >
<button <button
type="button" type="button"
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4) : s))} onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4 | 5) : s))}
disabled={step === 1} disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }} style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
> >
Back Back
</button> </button>
{step < 4 ? ( {step < 5 ? (
<button <button
type="button" type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4))} onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
disabled={!canNext || refining} disabled={!canNext || refining}
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }} style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
> >
@@ -383,6 +404,7 @@ export function ResearchWizard({
</button> </button>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
); );
+25
View File
@@ -18,6 +18,14 @@ export interface Loop {
created_by: string; created_by: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
agents: LoopAgentSlot[];
teams: string[];
orgs: string[];
}
export interface LoopAgentSlot {
agent_id: string;
role_slot: string | null;
} }
export interface LoopTriggers { export interface LoopTriggers {
@@ -38,6 +46,15 @@ export interface LoopCreated {
webhook_signing_key?: string; webhook_signing_key?: string;
} }
export interface LoopRepoRef {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> { async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
...init, ...init,
@@ -58,6 +75,10 @@ export const createLoop = (body: {
task_template: string; task_template: string;
triggers: LoopTriggers; triggers: LoopTriggers;
repeat_policy: LoopRepeatPolicy; repeat_policy: LoopRepeatPolicy;
repo?: LoopRepoRef | null;
agents?: { agent_id: string; role_slot?: string }[];
teams?: string[];
orgs?: string[];
}) => }) =>
api<LoopCreated>("/api/loops", { api<LoopCreated>("/api/loops", {
method: "POST", method: "POST",
@@ -73,6 +94,10 @@ export const patchLoop = (
task_template: string; task_template: string;
triggers: LoopTriggers; triggers: LoopTriggers;
repeat_policy: LoopRepeatPolicy; repeat_policy: LoopRepeatPolicy;
repo?: LoopRepoRef | null;
agents?: { agent_id: string; role_slot?: string }[];
teams?: string[];
orgs?: string[];
}, },
) => ) =>
api<void>(`/api/loops/${id}`, { api<void>(`/api/loops/${id}`, {
+11
View File
@@ -60,11 +60,21 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
export const listTopics = () => api<TopicListItem[]>("/api/research"); export const listTopics = () => api<TopicListItem[]>("/api/research");
export const getTopic = (id: string) => api<TopicDetail>(`/api/research/${id}`); export const getTopic = (id: string) => api<TopicDetail>(`/api/research/${id}`);
export interface TopicRepoRef {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
export const createTopic = (body: { export const createTopic = (body: {
title: string; title: string;
description: string; description: string;
outcome_kind: OutcomeKind; outcome_kind: OutcomeKind;
agents: { agent_id: string; role_slot?: string }[]; agents: { agent_id: string; role_slot?: string }[];
repo?: TopicRepoRef | null;
}) => }) =>
api<{ id: string }>("/api/research", { api<{ id: string }>("/api/research", {
method: "POST", method: "POST",
@@ -107,6 +117,7 @@ export const wizardRefine = (body: {
prompt: string; prompt: string;
prior_description?: string; prior_description?: string;
outcome_kind: OutcomeKind; outcome_kind: OutcomeKind;
repo?: TopicRepoRef | null;
}) => }) =>
api<{ description: string; suggested_title: string }>( api<{ description: string; suggested_title: string }>(
"/api/research/wizard/refine", "/api/research/wizard/refine",
+33
View File
@@ -0,0 +1,33 @@
-- Loop staffing: attach agents / teams / orgs to a loop so the run driver
-- knows who fills the role slots baked into loops.graph. Three parallel
-- join tables, one per selection tier — the wizard lets the user mix modes
-- (e.g. one team + two individual specialists).
--
-- All FKs cascade off loop_id so an existing hard-delete of a loop stays a
-- single-statement operation with no orphans. Agent/team/org deletions
-- also cascade so we never point at a phantom entity.
CREATE TABLE loop_agents (
loop_id UUID NOT NULL REFERENCES loops (id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
role_slot TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (loop_id, agent_id)
);
CREATE INDEX loop_agents_agent_idx ON loop_agents (agent_id);
CREATE TABLE loop_teams (
loop_id UUID NOT NULL REFERENCES loops (id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (loop_id, team_id)
);
CREATE INDEX loop_teams_team_idx ON loop_teams (team_id);
CREATE TABLE loop_orgs (
loop_id UUID NOT NULL REFERENCES loops (id) ON DELETE CASCADE,
org_id UUID NOT NULL REFERENCES orgs (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (loop_id, org_id)
);
CREATE INDEX loop_orgs_org_idx ON loop_orgs (org_id);