diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 998845c458..697479a1a5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12702,7 +12702,7 @@ paths: /w/{workspace}/runnables/list: get: - summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated + summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated operationId: listRunnables tags: - script @@ -12719,7 +12719,7 @@ paths: - $ref: "#/components/parameters/OrderDesc" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: show_archived @@ -12793,7 +12793,7 @@ paths: - $ref: "#/components/parameters/WorkspaceId" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: include_without_main @@ -30116,9 +30116,10 @@ components: description: | A row in the merged runnables listing. `type` is the discriminator; kind-specific fields (hash/language/kind for scripts, execution_mode/ - version for apps) are present only for that kind. `edited_at` is the - unified last-updated time (a script's created_at, a flow/app's edit - time). + version for apps, agent_memory/ws_specific for agents) are present only + for that kind. An agent is an `ai_agent` resource whose description is + reported as `summary`. `edited_at` is the unified last-updated time (a + script's created_at, a flow/app/agent's edit time). properties: type: type: string @@ -30126,6 +30127,7 @@ components: - script - flow - app + - agent path: type: string summary: @@ -30193,6 +30195,11 @@ components: version: type: integer format: int64 + agent_memory: + description: agent-only. The agent's `memory` setting. + ws_specific: + type: boolean + description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces. required: - type - path @@ -32387,9 +32394,6 @@ components: On a draft-only row, the path its editor has staged when it differs from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). - starred: - type: boolean - description: For an `ai_agent` resource only, whether the caller starred it. required: - path - resource_type diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 846089630b..e45b06bdcb 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -7,9 +7,10 @@ */ //! Unified, keyset-paginated listing of a workspace's runnables (scripts, -//! flows, apps) merged into one globally-ordered stream. The homepage uses it -//! so a chosen order (recently updated / oldest / name) is correct and complete -//! across all three kinds at any workspace size, instead of client-sorting a +//! flows, apps, and the `ai_agent` resources listed as agents) merged into one +//! globally-ordered stream. The homepage uses it so a chosen order (recently +//! updated / oldest / name) is correct and complete across every kind at any +//! workspace size, instead of client-sorting a //! per-kind capped window. //! //! Efficiency: each kind is a UNION ALL branch ordered by an index on @@ -51,7 +52,7 @@ struct ListRunnablesQuery { order_by: Option, /// Descending when true (default true). order_desc: Option, - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option, show_archived: Option, /// Include library scripts (no runnable main). Ignored for flows/apps. @@ -80,7 +81,7 @@ struct ListRunnablesQuery { #[derive(Serialize, sqlx::FromRow)] struct RunnableItem { #[serde(rename = "type")] - kind: String, // 'script' | 'flow' | 'app' + kind: String, // 'script' | 'flow' | 'app' | 'agent' path: String, #[serde(skip_serializing_if = "Option::is_none")] summary: Option, @@ -130,6 +131,11 @@ struct RunnableItem { id: Option, #[serde(skip_serializing_if = "Option::is_none")] version: Option, + // agent-only: the `memory` setting, which decides whether the agent opens as a chat. + #[serde(skip_serializing_if = "Option::is_none")] + agent_memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ws_specific: Option, // sort keys, echoed into the cursor (not serialized to the client) #[serde(skip)] sort_time: chrono::DateTime, @@ -247,7 +253,7 @@ fn scope_path_predicate( } } -/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` +/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` /// column set (NULL for columns that don't apply to that kind). `$1`=workspace, /// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request /// WHERE fragments are appended by the caller. @@ -255,6 +261,7 @@ struct Branches { script: String, flow: String, app: String, + agent: String, } fn branch_sqls() -> Branches { @@ -281,6 +288,7 @@ fn branch_sqls() -> Branches { o.codebase IS NOT NULL as use_codebase, \ (o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \ FROM script o \ LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -298,6 +306,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ (o.value->>'chat_input_enabled')::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM flow o \ LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -315,6 +324,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \ o.versions[array_upper(o.versions, 1)] as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM app o \ LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -323,25 +333,68 @@ fn branch_sqls() -> Branches { draft_users = draft_users("d.typ IN ('app', 'raw_app')") ); - Branches { script, flow, app } + // An agent is an `ai_agent` resource; the subquery renames its description to the + // `summary` the shared search and name sort read. + let agent = format!( + "SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \ + favorite.path IS NOT NULL as starred, false as archived, \ + draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \ + {draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \ + NULL::bool as ws_error_handler_muted, o.edited_at, \ + NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ + NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \ + COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ + FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \ + value->'memory' as agent_memory \ + FROM resource WHERE resource_type = 'ai_agent') o \ + LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ + LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \ + LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'", + draft_users = draft_users("d.typ = 'resource'") + ); + + Branches { script, flow, app, agent } +} + +/// The draft rows a kind lists and the table whose rows make a draft not draft-only. +fn draft_kind_source(kind: &str) -> (&'static str, &'static str) { + match kind { + "script" => ("d.typ = 'script'", "script"), + "flow" => ("d.typ = 'flow'", "flow"), + // Typed by the draft itself, as the resource listing filters its draft-only rows. + "agent" => ( + "d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'", + "resource", + ), + _ => ("d.typ IN ('app', 'raw_app')", "app"), + } +} + +/// Where a draft records the path its editor has staged. Scripts and resources bind the +/// Path widget to the item's own `path`, so it round-trips through the draft JSON's +/// `path`; flows and apps write a separate `draft_path` only when it differs from the +/// deployed one. See scripts.rs. +fn draft_typed_path(kind: &str) -> &'static str { + if kind == "script" || kind == "agent" { + "path" + } else { + "draft_path" + } } /// The draft-only branch for a kind: the caller's drafts at paths carrying no /// deployed row, projected into the same column set as `branch_sqls` so they /// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract. fn draft_branch_sql(kind: &str) -> String { - let (typ_pred, deployed) = match kind { - "script" => ("d.typ = 'script'", "script"), - "flow" => ("d.typ = 'flow'", "flow"), - _ => ("d.typ IN ('app', 'raw_app')", "app"), - }; - // Scripts bind the Path widget to `script.path`, so the typed path round-trips - // through the draft JSON's own `path`; flows and apps write a separate - // `draft_path` only when it differs from the deployed one. See scripts.rs. - let typed_path = if kind == "script" { - "path" + let (typ_pred, deployed) = draft_kind_source(kind); + let typed_path = draft_typed_path(kind); + // A resource draft carries the description where the other kinds carry a summary. + let summary_field = if kind == "agent" { + "description" } else { - "draft_path" + "summary" }; // `auto_kind` is only what the editor stamped into the draft — a `// pipeline` // annotation is not re-derived from the content here, unlike the per-kind @@ -351,7 +404,8 @@ fn draft_branch_sql(kind: &str) -> String { "script" => { "d.value->>'language' as language, d.value->>'kind' as script_kind, \ d.value->>'auto_kind' as auto_kind, false as raw_app, \ - NULL::bool as chat_input_enabled" + NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } // Type-guarded rather than a bare `::bool` cast: draft JSON is stored // unvalidated, so a malformed value must yield NULL, not abort the list, @@ -360,11 +414,20 @@ fn draft_branch_sql(kind: &str) -> String { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ false as raw_app, \ CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \ - THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled" + THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" + } + "agent" => { + "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + false as raw_app, NULL::bool as chat_input_enabled, \ + (d.value->'args'->'memory')::jsonb as agent_memory, \ + CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \ + THEN (d.value->>'wsSpecific')::bool END as ws_specific" } _ => { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ - (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled" + (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } }; format!( @@ -378,11 +441,12 @@ fn draft_branch_sql(kind: &str) -> String { NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \ NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \ o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, o.ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \ FROM ( \ SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \ d.email IS NULL as legacy, \ - COALESCE(d.value->>'summary', '') as summary, \ + COALESCE(d.value->>'{summary_field}', '') as summary, \ NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \ {kind_cols} \ FROM draft d \ @@ -397,6 +461,8 @@ fn draft_branch_sql(kind: &str) -> String { ) } +const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"]; + async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension, @@ -416,16 +482,16 @@ async fn list_runnables( }; let mut kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), Some(csv) => csv .split(',') .map(|s| s.trim()) - .filter(|s| ["script", "flow", "app"].contains(s)) + .filter(|s| ALL_KINDS.contains(s)) .collect(), }; - // Apps carry no `archived` column and are never listed as archived. + // Apps and agents carry no `archived` column and are never listed as archived. if show_archived { - kinds.retain(|k| *k != "app"); + kinds.retain(|k| *k != "app" && *k != "agent"); } let branches = branch_sqls(); @@ -533,6 +599,11 @@ async fn list_runnables( } else { None }; + let agent_scope = if kinds.contains(&"agent") { + scope_path_predicate(&authed, "resources", "o", 3, &mut binds) + } else { + None + }; // Per-kind archived predicate (scripts/flows have the column; apps don't and // are excluded from the archived view). @@ -570,6 +641,10 @@ async fn list_runnables( if let Some(s) = &app_scope { app_extras.push(s.clone()); } + let mut agent_extras: Vec = vec![]; + if let Some(s) = &agent_scope { + agent_extras.push(s.clone()); + } // Draft-only rows are the caller's own work in progress: never archived, so they // have no place in the archived view, and carrying no labels of their own they are @@ -584,6 +659,7 @@ async fn list_runnables( let scope = match kind { "script" => &script_scope, "flow" => &flow_scope, + "agent" => &agent_scope, _ => &app_scope, }; // The lib filter reads the same projected `auto_kind`, so a draft-only library @@ -644,6 +720,7 @@ async fn list_runnables( "script" => (&branches.script, &script_extras), "flow" => (&branches.flow, &flow_extras), "app" => (&branches.app, &app_extras), + "agent" => (&branches.agent, &agent_extras), _ => return None, }; Some(build_branch( @@ -700,7 +777,7 @@ async fn list_runnables( // No draft branch here: a draft-only path has no favorite row (the UI won't let // you star one), so it would scan the caller's whole draft slice per kind to // return nothing. The main stream below takes them unfiltered instead. - let starred_branches: Vec = ["script", "flow", "app"] + let starred_branches: Vec = ALL_KINDS .iter() .filter_map(|k| branch_for(k, Some(true), None, None)) .collect(); @@ -720,10 +797,10 @@ async fn list_runnables( // Main paged stream: non-starred rows (starred were pinned on the first page above). let main_fav = Some(false); let ns_branches: Vec = - ["script", "flow", "app"] + ALL_KINDS .iter() .filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))) - .chain(["script", "flow", "app"].iter().filter_map(|k| { + .chain(ALL_KINDS.iter().filter_map(|k| { draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)) })) .collect(); @@ -754,7 +831,7 @@ async fn list_runnables( #[derive(Deserialize)] struct CountRunnablesQuery { - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option, /// Include library scripts (no runnable main). Ignored for flows/apps. include_without_main: Option, @@ -810,13 +887,13 @@ async fn count_runnables_by_owner( Query(q): Query, ) -> JsonResult { let kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), // Deduplicated: every kind becomes its own count subquery, so a repeated // entry would both double that kind's count and multiply the scans. Some(csv) => { let mut ks: Vec<&str> = vec![]; for k in csv.split(',').map(|s| s.trim()) { - if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) { + if ALL_KINDS.contains(&k) && !ks.contains(&k) { ks.push(k); } } @@ -858,6 +935,13 @@ async fn count_runnables_by_owner( w.push(s); } } + "agent" => { + w.push(format!("{alias}.resource_type = 'ai_agent'")); + if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds) + { + w.push(s); + } + } _ => { if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) { w.push(s); @@ -869,6 +953,7 @@ async fn count_runnables_by_owner( let table_of = |kind: &str| match kind { "script" => "script", "flow" => "flow", + "agent" => "resource", _ => "app", }; @@ -1023,16 +1108,17 @@ async fn add_draft_counts( let branches: Vec = kinds .iter() .map(|kind| { - let (typ_pred, deployed, domain) = match *kind { - "script" => ("d.typ = 'script'", "script", "scripts"), - "flow" => ("d.typ = 'flow'", "flow", "flows"), - _ => ("d.typ IN ('app', 'raw_app')", "app", "apps"), + let (typ_pred, deployed) = draft_kind_source(kind); + let domain = match *kind { + "script" => "scripts", + "flow" => "flows", + "agent" => "resources", + _ => "apps", }; // The owner a draft counts under is where it says it will live, not the // `u//draft_` it is parked at — same path `/list` groups and - // filters on. Scripts round-trip the typed path through the draft JSON's - // own `path`; flows and apps write `draft_path`. See scripts.rs. - let typed_path = if *kind == "script" { "path" } else { "draft_path" }; + // filters on. + let typed_path = draft_typed_path(kind); let effective_path = format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path"); let mut w = vec![ diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 1a8035bab6..19ba8c529b 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -225,10 +225,6 @@ pub struct ListedResource { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(default)] pub draft_path: Option, - /// Whether the authed user starred this `ai_agent`; unset on every other type. - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(default)] - pub starred: Option, } #[derive(Deserialize)] @@ -380,12 +376,6 @@ async fn list_resources( AND draft.email = ?) as is_draft" .bind(&authed.email), ) - .field( - &"CASE WHEN resource.resource_type = 'ai_agent' THEN EXISTS(SELECT 1 FROM favorite \ - WHERE favorite.workspace_id = resource.workspace_id AND favorite.path = resource.path \ - AND favorite.favorite_kind = 'agent' AND favorite.usr = ?) END as starred" - .bind(&authed.username), - ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -581,7 +571,7 @@ async fn list_resources( // Synthesized rows are the authed user's draft. is_draft: Some(true), }; - rows.push(ListedResource { resource, agent_memory, draft_path, starred: None }); + rows.push(ListedResource { resource, agent_memory, draft_path }); } } diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index a4e6f04b33..359e1d38c3 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -10,7 +10,6 @@ type ListableApp, type Script, ScriptService, - ResourceService, type Flow, type ListableRawApp, type ListableResource, @@ -176,12 +175,7 @@ let itemKind = $derived( (filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all' ) - // Agents are `ai_agent` resources, listed through the resource endpoint: the runnables - // endpoint has no such kind, so it is asked for nothing on the agent view and for every - // kind on the combined one. - let showsRunnables = $derived(itemKind !== 'agent') - let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent') - let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind) + let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind) let archived = $derived(!!filterValues.val.archived) let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean) let filterUserFolders = $derived(!!filterValues.val.only_user_folders) @@ -319,11 +313,7 @@ let flows: TableFlow[] | undefined = $state() let apps: TableApp[] | undefined = $state() let raw_apps: TableRawApp[] | undefined = $state() - // Starts empty rather than undefined: the runnables page decides when the list renders, - // and agents join it when their own request lands. - let agents: TableAgent[] = $state([]) - let agentOwnerCounts: Record = $state({}) - let agentsGen = 0 + let agents: TableAgent[] | undefined = $state() // Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord). let fetchOrd = 0 @@ -354,7 +344,7 @@ } } - function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp { + function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent { const base = { ...it, canWrite: @@ -364,84 +354,14 @@ // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript + // An agent row reads its resource's description, which the endpoint reports as `summary`. + if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent return base as unknown as TableFlow | TableApp } - /** - * The workspace's saved agents, as the kind the home page shows them as. Read from the - * resource listing, which the runnables' keyset order knows nothing of, so the order is applied - * here and the rows are stamped before every runnable ordinal: the agents lead as one block and - * a page of runnables keeps the server's order. Leading rather than trailing, or the list's - * first window would never reach them past a page of runnables. - */ - async function loadAgents(): Promise { - const ws = $workspaceStore - const gen = ++agentsGen - if (!ws || !$userStore || !showsAgents || archived) { - agents = [] - agentOwnerCounts = {} - return - } - const rows: ListableResource[] = [] - try { - // Paged even though search and scoping run over the whole set here: the listing caps a - // page, and only the first carries the draft-only rows, so a short page ends it. - const perPage = 1000 - for (let page = 1; ; page++) { - const batch = await ResourceService.listResource({ - workspace: ws, - resourceType: 'ai_agent', - includeDraftOnly: true, - page, - perPage - }) - rows.push(...batch) - if (batch.length < perPage || gen !== agentsGen) break - } - } catch (e: any) { - if (gen !== agentsGen) return - sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true) - agents = [] - agentOwnerCounts = {} - return - } - if (gen !== agentsGen) return - // Counted before the owner scope: the chips and tree nodes list every owner. - const counts: Record = {} - for (const r of rows) { - const owner = effectivePath(r).split('/').slice(0, 2).join('/') - counts[owner] = (counts[owner] ?? 0) + 1 - } - agentOwnerCounts = counts - const scoped = ownerFilter - ? rows.filter((r) => effectivePath(r).startsWith(ownerFilter + '/')) - : rows - const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime() - const sorted = [...scoped].sort((a, b) => { - switch (sortOrder) { - case 'updated_asc': - return byTime(a) - byTime(b) - case 'name_asc': - return cmp(effectivePath(a), effectivePath(b)) - case 'name_desc': - return cmp(effectivePath(b), effectivePath(a)) - default: - return byTime(b) - byTime(a) - } - }) - agents = sorted.map((r, i) => ({ - ...r, - summary: r.description || undefined, - canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any), - ord: AGENT_ORD_BASE + i - })) - } - /** Below any ordinal a runnables page stamps (they count up from 0), so agents lead as a block. */ - const AGENT_ORD_BASE = -1_000_000 - // The merged, server-ordered, keyset-paginated source. `reset` reloads from // the first page (order/filter change or workspace switch); otherwise it - // appends the next page. All three kinds arrive interleaved and are split into + // appends the next page. Every kind arrives interleaved and are split into // the existing per-kind arrays so the downstream pipeline is unchanged. async function loadRunnables(reset: boolean): Promise { const ws = $workspaceStore @@ -455,25 +375,6 @@ // arrays (mixing streams) or clobber the pending reset's generation. if (!reset && serverCursor === undefined) return if (scripts === undefined) loading = true - // One load per reset: agents are fetched whole, so a load-more has nothing to append. - const agentsLoad = reset ? loadAgents() : undefined - if (!showsRunnables) { - // Supersedes a page still in flight, which would otherwise land in the agent view. - const gen = ++loadGen - serverCursor = undefined - hasMoreServer = false - // The rows on screen stay until the agents replace them, as a reload keeps them, or the - // view reads as empty in between. - await agentsLoad - if (gen !== loadGen) return - scripts = [] - flows = [] - apps = [] - raw_apps = [] - pipelineMemberFolders = new Set() - loading = false - return - } if (reset) { serverCursor = undefined hasMoreServer = false @@ -516,18 +417,13 @@ // A newer request superseded this one (e.g. order changed mid-flight); drop // this response so a stale page/cursor can't be mixed with the new order. if (gen !== loadGen) return - // Landed together with the agents fetched beside it, or a workspace holding only agents - // reads as empty until they arrive. - if (agentsLoad) { - await agentsLoad - if (gen !== loadGen) return - } serverCursor = res.next_cursor ?? undefined hasMoreServer = !!res.next_cursor const s: TableScript[] = reset ? [] : [...(scripts ?? [])] const f: TableFlow[] = reset ? [] : [...(flows ?? [])] const a: TableApp[] = reset ? [] : [...(apps ?? [])] + const ag: TableAgent[] = reset ? [] : [...(agents ?? [])] const memberFolders = reset ? new Set() : new Set(pipelineMemberFolders) if (reset) fetchOrd = 0 for (const it of res.items ?? []) { @@ -543,11 +439,14 @@ f.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableFlow) } else if (it.type === 'app') { a.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableApp) + } else if (it.type === 'agent') { + ag.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableAgent) } } scripts = s flows = f apps = a + agents = ag raw_apps = [] pipelineMemberFolders = memberFolders loading = false @@ -561,10 +460,13 @@ // skipping ones already present. Lets a search reach matches outside the loaded // browse window without re-fetching the whole list. function mergeRunnables(newItems: RunnableItem[]) { - const have = new Set([...(scripts ?? []), ...(flows ?? []), ...(apps ?? [])].map(itemKey)) + const have = new Set( + [...(scripts ?? []), ...(flows ?? []), ...(apps ?? []), ...(agents ?? [])].map(itemKey) + ) const s = [...(scripts ?? [])] const f = [...(flows ?? [])] const a = [...(apps ?? [])] + const ag = [...(agents ?? [])] const memberFolders = new Set(pipelineMemberFolders) let changed = false for (const it of newItems) { @@ -580,12 +482,14 @@ if (it.type === 'script') s.push(mapped as TableScript) else if (it.type === 'flow') f.push(mapped as TableFlow) else if (it.type === 'app') a.push(mapped as TableApp) + else if (it.type === 'agent') ag.push(mapped as TableAgent) changed = true } if (changed) { scripts = s flows = f apps = a + agents = ag pipelineMemberFolders = memberFolders } } @@ -653,13 +557,6 @@ // Track the prefix as open first — even a no-op call (re-expanding a cached node) // means it's on screen, so later reloads must refresh it. openOwners.add(owner) - // The agent view has no runnables to page in: its rows are the loaded agents, which - // `treeSource` adds itself. Rows an owner held from another kind go with the switch. - if (!showsRunnables) { - treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`)) - ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen } - return - } const st = ownerLoad[owner] // Only a load for the CURRENT generation blocks a new one. A load left in flight // by a superseded generation (treeGen bumped on a sort/filter reload) has already @@ -900,15 +797,13 @@ // each row carries its server fetch ordinal (`ord`), which already reflects the // chosen order, the (path, kind) tiebreaks, full-precision timestamps, the database // collation, and starred-first pinning. Sorting by it can never disagree with the - // server, so a later page never jumps above shown rows on "load more". Starred rows lead - // across kinds, agents included. Depends on + // server, so a later page never jumps above shown rows on "load more". Depends on // `sortOrder` only so its identity changes when the order does (re-grouping the tree, // whose leaves also sort by ord); the actual reordering comes from the reload that // restamps ord. let compareItems = $derived.by(() => { sortOrder - return (a: { ord?: number; starred?: boolean }, b: { ord?: number; starred?: boolean }) => - Number(!!b.starred) - Number(!!a.starred) || (a.ord ?? 0) - (b.ord ?? 0) + return (a: { ord?: number }, b: { ord?: number }): number => (a.ord ?? 0) - (b.ord ?? 0) }) const opts: uFuzzy.Options = { @@ -1104,7 +999,6 @@ [() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain], async ([ws, showArchived, kind, withoutMain]) => { if (!ws || showArchived) return undefined - if (kind === 'agent') return {} try { const res = await ScriptService.countRunnablesByOwner({ workspace: ws, @@ -1120,16 +1014,7 @@ } } ) - // The runnables endpoint never counts an agent, so the loaded agents are added per owner. - let ownerCounts = $derived.by(() => { - const runnables = ownerCountsRes.current - if (runnables == undefined) return undefined - const counts = { ...runnables } - for (const [owner, n] of Object.entries(agentOwnerCounts)) { - counts[owner] = (counts[owner] ?? 0) + n - } - return counts - }) + let ownerCounts = $derived(ownerCountsRes.current) // The counts decide which owners the tree renders, so drawing it before they land // would show every workspace folder and then prune it away. Hold the skeleton // until the first response instead — it is fetched in parallel with the listing, @@ -1311,8 +1196,7 @@ const kind = itemKind // Any term/scope change restarts search paging. searchCursor = undefined - // Agents are all loaded, so the client-side search over them is already complete. - if (term === '' || !ws || !$userStore || kind === 'agent') return + if (term === '' || !ws || !$userStore) return const handle = setTimeout(async () => { let res: { items: RunnableItem[]; next_cursor?: string } try { @@ -1368,7 +1252,7 @@ search: term, showArchived: showArchived ? true : undefined, includeWithoutMain: withoutMain ? true : undefined, - kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined, + kinds: kind !== 'all' ? kind : undefined, pathStart: owner ? owner + '/' : undefined, includeDraftOnly: true, perPage: 1000, @@ -1420,7 +1304,7 @@ type: 'raw_app' as 'raw_app', time: new Date(x.edited_at).getTime() })), - ...agents.map((x) => ({ + ...(agents ?? []).map((x) => ({ ...x, type: 'agent' as 'agent', time: new Date(x.edited_at ?? 0).getTime() @@ -1484,12 +1368,10 @@ // injected as a node and its rows come from the on-demand `treeOwnerItems` store, // so the tree never depends on which owners happen to be in the loaded window. // Otherwise (scoped/search/label) the tree just groups the global `items`. - // Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint, - // which never returns one, while the resource listing already holds them all. let treeSource = $derived( treeLazyMode - ? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter( - (x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) + ? treeOwnerItems.filter((x) => + filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) ) : items )