loops: repo picker + agent/team/org staffing + sidebar edit/delete
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:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM loop_orgs WHERE loop_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "01cdcd2bbc04ebfefb5ba2a69bee88ddb732a88117f63841bfd17d07a4b969af"
|
||||||
|
}
|
||||||
+15
@@ -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"
|
||||||
|
}
|
||||||
+28
@@ -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"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM loop_agents WHERE loop_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3f74f01c6411409c49ad66edba8a059ca8a72daae3570a6e99d59de7c3b6dc82"
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
-34
@@ -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"
|
|
||||||
}
|
|
||||||
+22
@@ -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"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM loop_teams WHERE loop_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c73f821ea00857bcc06add6478a4f999bbf8bdecb96e9fb79cdeafd1d22a2a2f"
|
||||||
|
}
|
||||||
+15
@@ -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"
|
||||||
|
}
|
||||||
-18
@@ -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"
|
|
||||||
}
|
|
||||||
+22
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,70 +190,169 @@ 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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
style={{
|
type="button"
|
||||||
fontSize: 13.5,
|
onClick={() => onSelect(l.id)}
|
||||||
fontWeight: 600,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
|
all: "unset",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eaeaee",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
gap: 6,
|
gap: 4,
|
||||||
fontFamily: mono,
|
|
||||||
fontSize: 10,
|
|
||||||
color: "#8a8a92",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 7,
|
fontSize: 13.5,
|
||||||
height: 7,
|
fontWeight: 600,
|
||||||
borderRadius: "50%",
|
overflow: "hidden",
|
||||||
background: dot,
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<span style={{ color: dot }}>{l.enabled ? "enabled" : "disabled"}</span>
|
{l.title}
|
||||||
<span style={{ opacity: 0.5 }}>·</span>
|
</div>
|
||||||
<span>
|
<div
|
||||||
{l.next_fire_at
|
style={{
|
||||||
? `next ${new Date(l.next_fire_at).toLocaleString()}`
|
display: "flex",
|
||||||
: "no schedule"}
|
alignItems: "center",
|
||||||
</span>
|
gap: 6,
|
||||||
</div>
|
fontFamily: mono,
|
||||||
</button>
|
fontSize: 10,
|
||||||
|
color: "#8a8a92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: dot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: dot }}>
|
||||||
|
{l.enabled ? "enabled" : "disabled"}
|
||||||
|
</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>
|
||||||
|
{l.next_fire_at
|
||||||
|
? `next ${new Date(l.next_fire_at).toLocaleString()}`
|
||||||
|
: "no schedule"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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",
|
||||||
|
};
|
||||||
|
|||||||
@@ -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're never shown again. To rotate, disable
|
Save these now — they'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'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 ("lead", "critic") groups avatars on the canvas.</p>
|
<p style={hintStyle}>Zero or more. Each optional role slot ("lead", "critic") 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user