feat: list agents in the merged runnables stream so they sort with everything else

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB
This commit is contained in:
hugocasa
2026-10-09 19:32:01 +02:00
co-authored by Claude Opus 5.5
parent 5f4bab4dc7
commit 837358e962
4 changed files with 163 additions and 201 deletions
+13 -9
View File
@@ -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
+125 -39
View File
@@ -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<String>,
/// Descending when true (default true).
order_desc: Option<bool>,
/// Comma-separated subset of `script,flow,app`; omitted means all.
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
kinds: Option<String>,
show_archived: Option<bool>,
/// 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<String>,
@@ -130,6 +131,11 @@ struct RunnableItem {
id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<i64>,
// 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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
ws_specific: Option<bool>,
// sort keys, echoed into the cursor (not serialized to the client)
#[serde(skip)]
sort_time: chrono::DateTime<chrono::Utc>,
@@ -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<UserDB>,
@@ -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<String> = 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<String> = ["script", "flow", "app"]
let starred_branches: Vec<String> = 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<String> =
["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<String>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
include_without_main: Option<bool>,
@@ -810,13 +887,13 @@ async fn count_runnables_by_owner(
Query(q): Query<CountRunnablesQuery>,
) -> JsonResult<RunnableCountsResponse> {
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<String> = 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/<caller>/draft_<uuid>` 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![
+1 -11
View File
@@ -225,10 +225,6 @@ pub struct ListedResource {
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub draft_path: Option<String>,
/// 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<bool>,
}
#[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 });
}
}
+24 -142
View File
@@ -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<string, number> = $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<void> {
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<string, number> = {}
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<void> {
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<string>() : 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
)