mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 16:02:36 +00:00
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:
co-authored by
Claude Opus 5.5
parent
5f4bab4dc7
commit
837358e962
@@ -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
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user