@desplega.ai/agent-swarm 1.81.0 → 1.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/openapi.json +132 -1
  2. package/package.json +3 -2
  3. package/src/be/db.ts +610 -96
  4. package/src/be/migrations/069_agent_tasks_perf_indexes.sql +17 -0
  5. package/src/be/migrations/070_seed_state.sql +22 -0
  6. package/src/be/migrations/071_codex_oauth_pool.sql +8 -0
  7. package/src/be/migrations/072_task_attachments.sql +38 -0
  8. package/src/be/scripts/typecheck.ts +450 -7
  9. package/src/be/seed/index.ts +9 -0
  10. package/src/be/seed/registry.ts +18 -0
  11. package/src/be/seed/runner.ts +98 -0
  12. package/src/be/seed/state-db.ts +36 -0
  13. package/src/be/seed/types.ts +59 -0
  14. package/src/be/seed-scripts/catalog/date-resolve.ts +104 -0
  15. package/src/be/seed-scripts/catalog/fetch-readable.ts +77 -0
  16. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +104 -0
  17. package/src/be/seed-scripts/catalog/group-count.ts +70 -0
  18. package/src/be/seed-scripts/catalog/json-query.ts +124 -0
  19. package/src/be/seed-scripts/catalog/linear-issue.ts +103 -0
  20. package/src/be/seed-scripts/catalog/memory-dedup-check.ts +61 -0
  21. package/src/be/seed-scripts/catalog/slack-thread-flatten.ts +86 -0
  22. package/src/be/seed-scripts/catalog/task-failure-audit.ts +87 -0
  23. package/src/be/seed-scripts/catalog/text-diff.ts +103 -0
  24. package/src/be/seed-scripts/index.ts +183 -0
  25. package/src/cli.tsx +2 -0
  26. package/src/commands/codex-login.ts +36 -6
  27. package/src/commands/runner.ts +133 -44
  28. package/src/github/handlers.ts +7 -7
  29. package/src/http/agents.ts +7 -1
  30. package/src/http/index.ts +123 -74
  31. package/src/http/pages.ts +25 -5
  32. package/src/http/route-def.ts +63 -0
  33. package/src/http/schedules.ts +11 -2
  34. package/src/http/scripts.ts +10 -1
  35. package/src/http/sessions.ts +31 -3
  36. package/src/http/skills.ts +8 -1
  37. package/src/http/stats.ts +19 -0
  38. package/src/http/tasks.ts +13 -2
  39. package/src/http/utils.ts +44 -0
  40. package/src/http/workflows.ts +11 -1
  41. package/src/http.ts +2 -0
  42. package/src/otel-impl.ts +34 -4
  43. package/src/otel.ts +1 -3
  44. package/src/providers/claude-adapter.ts +61 -0
  45. package/src/providers/codex-adapter.ts +22 -1
  46. package/src/providers/codex-oauth/auth-json-fs.ts +52 -0
  47. package/src/providers/codex-oauth/auth-json.ts +3 -3
  48. package/src/providers/codex-oauth/storage.ts +81 -21
  49. package/src/providers/otel-env.ts +63 -0
  50. package/src/providers/types.ts +5 -0
  51. package/src/scripts-runtime/eval-harness.ts +70 -3
  52. package/src/scripts-runtime/executors/native.ts +19 -1
  53. package/src/scripts-runtime/executors/types.ts +17 -0
  54. package/src/scripts-runtime/loader.ts +2 -0
  55. package/src/server.ts +2 -0
  56. package/src/tests/claude-adapter-otel.test.ts +225 -0
  57. package/src/tests/codex-adapter-otel.test.ts +120 -0
  58. package/src/tests/codex-login.test.ts +142 -0
  59. package/src/tests/codex-oauth-adapter.test.ts +108 -0
  60. package/src/tests/codex-oauth-auth-json-fs.test.ts +112 -0
  61. package/src/tests/codex-oauth-storage.test.ts +262 -86
  62. package/src/tests/codex-pool.test.ts +284 -0
  63. package/src/tests/github-handlers.test.ts +29 -0
  64. package/src/tests/http-semconv-attributes.test.ts +92 -0
  65. package/src/tests/list-endpoint-slimming.test.ts +179 -0
  66. package/src/tests/mcp-tools-user.test.ts +48 -1
  67. package/src/tests/otel-env.test.ts +103 -0
  68. package/src/tests/otel-service-name.test.ts +55 -0
  69. package/src/tests/pagination-metrics.test.ts +165 -0
  70. package/src/tests/prompt-template-resolver.test.ts +1 -1
  71. package/src/tests/route-def-find-route.test.ts +106 -0
  72. package/src/tests/scripts-http.test.ts +110 -0
  73. package/src/tests/scripts-runtime.test.ts +1 -0
  74. package/src/tests/scripts-typecheck.test.ts +175 -0
  75. package/src/tests/seed-scripts.test.ts +220 -0
  76. package/src/tests/seed.test.ts +163 -0
  77. package/src/tests/send-task-requested-by.test.ts +154 -0
  78. package/src/tests/sessions.test.ts +53 -0
  79. package/src/tests/store-progress-attachments.test.ts +312 -0
  80. package/src/tests/workflow-http-v2.test.ts +16 -2
  81. package/src/tools/get-metrics.ts +46 -0
  82. package/src/tools/get-swarm.ts +10 -3
  83. package/src/tools/get-task-details.ts +15 -2
  84. package/src/tools/get-tasks.ts +22 -5
  85. package/src/tools/resolve-user.ts +25 -10
  86. package/src/tools/schedules/list-schedules.ts +15 -4
  87. package/src/tools/send-task.ts +16 -0
  88. package/src/tools/store-progress.ts +66 -4
  89. package/src/tools/tool-config.ts +2 -1
  90. package/src/tools/utils.ts +3 -1
  91. package/src/tools/workflows/list-workflows.ts +12 -3
  92. package/src/types.ts +128 -0
  93. package/src/utils/internal-ai/register-bedrock.ts +34 -0
  94. package/src/utils/secret-scrubber.ts +3 -0
  95. /package/src/be/{seed.ts → seed-prompt-templates.ts} +0 -0
package/src/be/db.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  AgentTask,
16
16
  AgentTaskSource,
17
17
  AgentTaskStatus,
18
+ AgentTaskSummary,
18
19
  AgentWithTasks,
19
20
  Budget,
20
21
  BudgetRefusalCause,
@@ -44,6 +45,7 @@ import type {
44
45
  PageAuthMode,
45
46
  PageContentType,
46
47
  PageSnapshot,
48
+ PageSummary,
47
49
  PageVersion,
48
50
  PricingProvider,
49
51
  PricingRow,
@@ -53,6 +55,7 @@ import type {
53
55
  ProviderName,
54
56
  RepoGuidelines,
55
57
  ScheduledTask,
58
+ ScheduledTaskSummary,
56
59
  Service,
57
60
  ServiceStatus,
58
61
  SessionCost,
@@ -64,6 +67,7 @@ import type {
64
67
  SkillWithInstallInfo,
65
68
  SwarmConfig,
66
69
  SwarmRepo,
70
+ TaskAttachment,
67
71
  TaskTemplate,
68
72
  TaskTemplateKind,
69
73
  TriggerConfig,
@@ -80,6 +84,7 @@ import type {
80
84
  WorkflowRunStep,
81
85
  WorkflowRunStepStatus,
82
86
  WorkflowSnapshot,
87
+ WorkflowSummary,
83
88
  WorkflowVersion,
84
89
  } from "../types";
85
90
  import { deriveProviderFromKeyType } from "../utils/credentials";
@@ -87,7 +92,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
87
92
  import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
88
93
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
89
94
  import { runMigrations } from "./migrations/runner";
90
- import { seedDefaultTemplates } from "./seed";
95
+ import { seedDefaultTemplates } from "./seed-prompt-templates";
91
96
  import { isReservedConfigKey, reservedKeyError } from "./swarm-config-guard";
92
97
 
93
98
  let db: Database | null = null;
@@ -128,6 +133,10 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
128
133
  database.run("PRAGMA journal_mode = WAL;");
129
134
  database.run("PRAGMA busy_timeout = 5000;");
130
135
  database.run("PRAGMA foreign_keys = ON;");
136
+ database.run("PRAGMA synchronous = NORMAL;");
137
+ database.run("PRAGMA cache_size = -64000;");
138
+ database.run("PRAGMA mmap_size = 268435456;");
139
+ database.run("PRAGMA temp_store = MEMORY;");
131
140
 
132
141
  // Load sqlite-vec extension for vector search.
133
142
  // In compiled binaries (`bun build --compile`) the JS lives in /$bunfs/ and
@@ -575,8 +584,15 @@ type AgentRow = {
575
584
  cred_status: string | null;
576
585
  };
577
586
 
578
- function rowToAgent(row: AgentRow): Agent {
579
- return {
587
+ /**
588
+ * Map an agent row to the `Agent` shape. When `slim` is true the six identity
589
+ * markdown blobs (`claudeMd`/`soulMd`/`identityMd`/`toolsMd`/`heartbeatMd`/
590
+ * `setupScript`) are omitted — they bloat list responses by ~16 KB/agent and
591
+ * are never needed at the swarm-overview level. Fetch them via
592
+ * `GET /api/agents/{id}` when required.
593
+ */
594
+ function rowToAgent(row: AgentRow, slim = false): Agent {
595
+ const base: Agent = {
580
596
  id: row.id,
581
597
  name: row.name,
582
598
  isLead: row.isLead === 1,
@@ -586,12 +602,6 @@ function rowToAgent(row: AgentRow): Agent {
586
602
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
587
603
  maxTasks: row.maxTasks ?? 1,
588
604
  emptyPollCount: row.emptyPollCount ?? 0,
589
- claudeMd: row.claudeMd ?? undefined,
590
- soulMd: row.soulMd ?? undefined,
591
- identityMd: row.identityMd ?? undefined,
592
- setupScript: row.setupScript ?? undefined,
593
- toolsMd: row.toolsMd ?? undefined,
594
- heartbeatMd: row.heartbeatMd ?? undefined,
595
605
  lastActivityAt: row.lastActivityAt ?? undefined,
596
606
  provider: (row.provider as ProviderName | null) ?? undefined,
597
607
  harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
@@ -602,6 +612,16 @@ function rowToAgent(row: AgentRow): Agent {
602
612
  : null,
603
613
  credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
604
614
  };
615
+ if (slim) return base;
616
+ return {
617
+ ...base,
618
+ claudeMd: row.claudeMd ?? undefined,
619
+ soulMd: row.soulMd ?? undefined,
620
+ identityMd: row.identityMd ?? undefined,
621
+ setupScript: row.setupScript ?? undefined,
622
+ toolsMd: row.toolsMd ?? undefined,
623
+ heartbeatMd: row.heartbeatMd ?? undefined,
624
+ };
605
625
  }
606
626
 
607
627
  export const agentQueries = {
@@ -680,8 +700,11 @@ export function getAgentById(id: string): Agent | null {
680
700
  return row ? rowToAgent(row) : null;
681
701
  }
682
702
 
683
- export function getAllAgents(): Agent[] {
684
- return agentQueries.getAll().all().map(rowToAgent);
703
+ export function getAllAgents(opts?: { slim?: boolean }): Agent[] {
704
+ return agentQueries
705
+ .getAll()
706
+ .all()
707
+ .map((row) => rowToAgent(row, opts?.slim ?? false));
685
708
  }
686
709
 
687
710
  export function getLeadAgent(): Agent | null {
@@ -778,7 +801,7 @@ export function listAgentsWithCredStatusByProvider(provider: string): Agent[] {
778
801
  const rows = getDb()
779
802
  .prepare<AgentRow, [string]>(`SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`)
780
803
  .all(provider);
781
- return rows.map(rowToAgent);
804
+ return rows.map((row) => rowToAgent(row));
782
805
  }
783
806
 
784
807
  /**
@@ -1055,6 +1078,41 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1055
1078
  };
1056
1079
  }
1057
1080
 
1081
+ /**
1082
+ * Slim list-row mapper — truncates the `task` text to a bounded preview and
1083
+ * drops completion/integration/context blobs (`output`, `failureReason`,
1084
+ * `providerMeta`, all `vcs*`/`slack*`/`agentmail*`/`credential*`/`mention*` and
1085
+ * context-window fields). The preview is long enough for pool-triage; the full
1086
+ * brief is on `get-task-details` / `GET /api/tasks/{id}`.
1087
+ */
1088
+ function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
1089
+ const t = rowToAgentTask(row);
1090
+ return {
1091
+ id: t.id,
1092
+ agentId: t.agentId,
1093
+ creatorAgentId: t.creatorAgentId,
1094
+ task: previewText(t.task, TASK_PREVIEW_LENGTH),
1095
+ status: t.status,
1096
+ source: t.source,
1097
+ taskType: t.taskType,
1098
+ tags: t.tags,
1099
+ priority: t.priority,
1100
+ dependsOn: t.dependsOn,
1101
+ offeredTo: t.offeredTo,
1102
+ acceptedAt: t.acceptedAt,
1103
+ parentTaskId: t.parentTaskId,
1104
+ scheduleId: t.scheduleId,
1105
+ model: t.model,
1106
+ provider: t.provider,
1107
+ requestedByUserId: t.requestedByUserId,
1108
+ progress: t.progress,
1109
+ createdAt: t.createdAt,
1110
+ lastUpdatedAt: t.lastUpdatedAt,
1111
+ finishedAt: t.finishedAt,
1112
+ peakContextPercent: t.peakContextPercent,
1113
+ };
1114
+ }
1115
+
1058
1116
  export const taskQueries = {
1059
1117
  insert: () =>
1060
1118
  getDb().prepare<
@@ -1224,7 +1282,7 @@ export function markTaskSlackReplySent(taskId: string): void {
1224
1282
  export function getChildTasks(parentTaskId: string): AgentTask[] {
1225
1283
  return getDb()
1226
1284
  .prepare<AgentTaskRow, [string]>(
1227
- `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC`,
1285
+ `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC, rowid ASC`,
1228
1286
  )
1229
1287
  .all(parentTaskId)
1230
1288
  .map(rowToAgentTask);
@@ -1348,7 +1406,15 @@ export interface TaskFilters {
1348
1406
  includeHeartbeat?: boolean;
1349
1407
  }
1350
1408
 
1351
- export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1409
+ export function getAllTasks(filters?: TaskFilters): AgentTask[];
1410
+ export function getAllTasks(
1411
+ filters: TaskFilters | undefined,
1412
+ opts: { slim: true },
1413
+ ): AgentTaskSummary[];
1414
+ export function getAllTasks(
1415
+ filters?: TaskFilters,
1416
+ opts?: { slim?: boolean },
1417
+ ): AgentTask[] | AgentTaskSummary[] {
1352
1418
  const conditions: string[] = [];
1353
1419
  const params: (string | AgentTaskStatus)[] = [];
1354
1420
 
@@ -1433,19 +1499,25 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1433
1499
  const offset = filters?.offset ?? 0;
1434
1500
  const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1435
1501
 
1436
- let tasks = getDb()
1502
+ const rows = getDb()
1437
1503
  .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
1438
- .all(...params)
1439
- .map(rowToAgentTask);
1504
+ .all(...params);
1440
1505
 
1441
- // Filter for ready tasks (dependencies met) if requested
1442
- if (filters?.readyOnly) {
1443
- tasks = tasks.filter((task) => {
1444
- if (!task.dependsOn || task.dependsOn.length === 0) return true;
1445
- return checkDependencies(task.id).ready;
1446
- });
1506
+ // Filter for ready tasks (dependencies met) if requested. Both the full and
1507
+ // the slim row shapes carry `id` + `dependsOn`, so the same predicate works.
1508
+ const isReady = (task: { id: string; dependsOn: string[] }): boolean => {
1509
+ if (!task.dependsOn || task.dependsOn.length === 0) return true;
1510
+ return checkDependencies(task.id).ready;
1511
+ };
1512
+
1513
+ if (opts?.slim) {
1514
+ let tasks = rows.map(rowToAgentTaskSummary);
1515
+ if (filters?.readyOnly) tasks = tasks.filter(isReady);
1516
+ return tasks;
1447
1517
  }
1448
1518
 
1519
+ let tasks = rows.map(rowToAgentTask);
1520
+ if (filters?.readyOnly) tasks = tasks.filter(isReady);
1449
1521
  return tasks;
1450
1522
  }
1451
1523
 
@@ -2000,7 +2072,7 @@ export function getPausedTasksForAgent(agentId: string): AgentTask[] {
2000
2072
  .prepare<AgentTaskRow, [string]>(
2001
2073
  `SELECT * FROM agent_tasks
2002
2074
  WHERE agentId = ? AND status = 'paused'
2003
- ORDER BY createdAt ASC`,
2075
+ ORDER BY createdAt ASC, rowid ASC`,
2004
2076
  )
2005
2077
  .all(agentId);
2006
2078
  return rows.map(rowToAgentTask);
@@ -2054,6 +2126,172 @@ export function updateTaskProgress(id: string, progress: string): AgentTask | nu
2054
2126
  return row ? rowToAgentTask(row) : null;
2055
2127
  }
2056
2128
 
2129
+ // ============================================================================
2130
+ // Task Attachments (Phase 1 — pointer-based artifacts)
2131
+ // ============================================================================
2132
+ //
2133
+ // Pointer-only attachments live in their own table; `agent_tasks` is
2134
+ // untouched. Append-only in Phase 1 — `insertTaskAttachment` silently no-ops
2135
+ // on a duplicate (sha256 match, or kind+pointer+name tuple match) so
2136
+ // idempotent re-calls don't fan out duplicate rows. The `kind` enum here
2137
+ // MUST stay in sync with the SQL CHECK constraint (migration 072) and the
2138
+ // `TaskAttachmentKindSchema` zod enum.
2139
+
2140
+ type TaskAttachmentRow = {
2141
+ id: string;
2142
+ task_id: string;
2143
+ agent_id: string | null;
2144
+ name: string;
2145
+ kind: string;
2146
+ url: string | null;
2147
+ path: string | null;
2148
+ page_id: string | null;
2149
+ mime_type: string | null;
2150
+ size_bytes: number | null;
2151
+ sha256: string | null;
2152
+ intent: string | null;
2153
+ description: string | null;
2154
+ is_primary: number;
2155
+ created_at: string;
2156
+ };
2157
+
2158
+ function rowToTaskAttachment(row: TaskAttachmentRow): TaskAttachment {
2159
+ return {
2160
+ id: row.id,
2161
+ taskId: row.task_id,
2162
+ agentId: row.agent_id,
2163
+ name: row.name,
2164
+ kind: row.kind as TaskAttachment["kind"],
2165
+ url: row.url ?? undefined,
2166
+ path: row.path ?? undefined,
2167
+ pageId: row.page_id ?? undefined,
2168
+ mimeType: row.mime_type ?? undefined,
2169
+ sizeBytes: row.size_bytes ?? undefined,
2170
+ sha256: row.sha256 ?? undefined,
2171
+ intent: row.intent ?? undefined,
2172
+ description: row.description ?? undefined,
2173
+ isPrimary: !!row.is_primary,
2174
+ createdAt: row.created_at,
2175
+ };
2176
+ }
2177
+
2178
+ export interface InsertTaskAttachmentInput {
2179
+ taskId: string;
2180
+ agentId: string | null;
2181
+ name: string;
2182
+ kind: TaskAttachment["kind"];
2183
+ url?: string;
2184
+ path?: string;
2185
+ pageId?: string;
2186
+ mimeType?: string;
2187
+ sizeBytes?: number;
2188
+ sha256?: string;
2189
+ intent?: string;
2190
+ description?: string;
2191
+ isPrimary?: boolean;
2192
+ }
2193
+
2194
+ /**
2195
+ * Insert a task attachment. Append-only + dedup:
2196
+ * - if sha256 is present and a row for this task already has that sha256,
2197
+ * skip (return existing row);
2198
+ * - otherwise skip if a row exists for the same task with the same
2199
+ * (kind, path|url|page_id, name) tuple.
2200
+ * Returns the stored attachment (newly inserted or pre-existing duplicate).
2201
+ */
2202
+ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAttachment {
2203
+ const db = getDb();
2204
+
2205
+ if (input.sha256) {
2206
+ const existing = db
2207
+ .prepare<TaskAttachmentRow, [string, string]>(
2208
+ "SELECT * FROM task_attachments WHERE task_id = ? AND sha256 = ? LIMIT 1",
2209
+ )
2210
+ .get(input.taskId, input.sha256);
2211
+ if (existing) return rowToTaskAttachment(existing);
2212
+ }
2213
+
2214
+ const tupleExisting = db
2215
+ .prepare<TaskAttachmentRow, [string, string, string, string, string, string]>(
2216
+ `SELECT * FROM task_attachments
2217
+ WHERE task_id = ?
2218
+ AND kind = ?
2219
+ AND IFNULL(path, '') = ?
2220
+ AND IFNULL(url, '') = ?
2221
+ AND IFNULL(page_id, '') = ?
2222
+ AND name = ?
2223
+ ORDER BY created_at ASC
2224
+ LIMIT 1`,
2225
+ )
2226
+ .get(
2227
+ input.taskId,
2228
+ input.kind,
2229
+ input.path ?? "",
2230
+ input.url ?? "",
2231
+ input.pageId ?? "",
2232
+ input.name,
2233
+ );
2234
+ if (tupleExisting) return rowToTaskAttachment(tupleExisting);
2235
+
2236
+ const id = crypto.randomUUID();
2237
+ const row = db
2238
+ .prepare<
2239
+ TaskAttachmentRow,
2240
+ [
2241
+ string,
2242
+ string,
2243
+ string | null,
2244
+ string,
2245
+ string,
2246
+ string | null,
2247
+ string | null,
2248
+ string | null,
2249
+ string | null,
2250
+ number | null,
2251
+ string | null,
2252
+ string | null,
2253
+ string | null,
2254
+ number,
2255
+ ]
2256
+ >(
2257
+ `INSERT INTO task_attachments
2258
+ (id, task_id, agent_id, name, kind, url, path, page_id,
2259
+ mime_type, size_bytes, sha256, intent, description, is_primary)
2260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2261
+ RETURNING *`,
2262
+ )
2263
+ .get(
2264
+ id,
2265
+ input.taskId,
2266
+ input.agentId ?? null,
2267
+ input.name,
2268
+ input.kind,
2269
+ input.url ?? null,
2270
+ input.path ?? null,
2271
+ input.pageId ?? null,
2272
+ input.mimeType ?? null,
2273
+ input.sizeBytes ?? null,
2274
+ input.sha256 ?? null,
2275
+ input.intent ?? null,
2276
+ input.description ?? null,
2277
+ input.isPrimary ? 1 : 0,
2278
+ );
2279
+
2280
+ if (!row) {
2281
+ throw new Error("Failed to insert task attachment");
2282
+ }
2283
+ return rowToTaskAttachment(row);
2284
+ }
2285
+
2286
+ export function getTaskAttachments(taskId: string): TaskAttachment[] {
2287
+ return getDb()
2288
+ .prepare<TaskAttachmentRow, [string]>(
2289
+ "SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, rowid ASC",
2290
+ )
2291
+ .all(taskId)
2292
+ .map(rowToTaskAttachment);
2293
+ }
2294
+
2057
2295
  // ============================================================================
2058
2296
  // Combined Queries (Agent with Tasks)
2059
2297
  // ============================================================================
@@ -2070,9 +2308,9 @@ export function getAgentWithTasks(id: string): AgentWithTasks | null {
2070
2308
  return txn();
2071
2309
  }
2072
2310
 
2073
- export function getAllAgentsWithTasks(): AgentWithTasks[] {
2311
+ export function getAllAgentsWithTasks(opts?: { slim?: boolean }): AgentWithTasks[] {
2074
2312
  const txn = getDb().transaction(() => {
2075
- const agents = getAllAgents();
2313
+ const agents = getAllAgents({ slim: opts?.slim ?? false });
2076
2314
  return agents.map((agent) => ({
2077
2315
  ...agent,
2078
2316
  tasks: getTasksByAgentId(agent.id),
@@ -2166,8 +2404,13 @@ export function getLogsByAgentId(agentId: string): AgentLog[] {
2166
2404
  return logQueries.getByAgentId().all(agentId).map(rowToAgentLog);
2167
2405
  }
2168
2406
 
2169
- export function getLogsByTaskId(taskId: string): AgentLog[] {
2170
- return logQueries.getByTaskId().all(taskId).map(rowToAgentLog);
2407
+ export function getLogsByTaskId(taskId: string, limit = 200): AgentLog[] {
2408
+ return getDb()
2409
+ .prepare<AgentLogRow, [string, number]>(
2410
+ "SELECT * FROM agent_log WHERE taskId = ? ORDER BY createdAt DESC LIMIT ?",
2411
+ )
2412
+ .all(taskId, limit)
2413
+ .map(rowToAgentLog);
2171
2414
  }
2172
2415
 
2173
2416
  export function getLogsByTaskIdChronological(taskId: string): AgentLog[] {
@@ -2629,7 +2872,7 @@ export function releaseStaleReviewingTasks(timeoutMinutes: number = 30): number
2629
2872
  export function getOfferedTasksForAgent(agentId: string): AgentTask[] {
2630
2873
  return getDb()
2631
2874
  .prepare<AgentTaskRow, [string]>(
2632
- "SELECT * FROM agent_tasks WHERE offeredTo = ? AND status = 'offered' ORDER BY createdAt ASC",
2875
+ "SELECT * FROM agent_tasks WHERE offeredTo = ? AND status = 'offered' ORDER BY createdAt ASC, rowid ASC",
2633
2876
  )
2634
2877
  .all(agentId)
2635
2878
  .map(rowToAgentTask);
@@ -2682,7 +2925,7 @@ export function getUnassignedTasksCount(): number {
2682
2925
  export function getUnassignedTaskIds(limit = 10): string[] {
2683
2926
  const rows = getDb()
2684
2927
  .prepare<{ id: string }, [number]>(
2685
- "SELECT id FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC LIMIT ?",
2928
+ "SELECT id FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC, rowid ASC LIMIT ?",
2686
2929
  )
2687
2930
  .all(limit);
2688
2931
  return rows.map((r) => r.id);
@@ -4477,6 +4720,21 @@ type ScheduledTaskRow = {
4477
4720
  lastUpdatedAt: string;
4478
4721
  };
4479
4722
 
4723
+ // ── List-endpoint slimming helpers ──────────────────────────────────────────
4724
+ // List endpoints ship slim rows by default; heavy text fields are replaced
4725
+ // with bounded previews. Lengths are generous enough for triage/recognition
4726
+ // while keeping list payloads small.
4727
+ /** Preview length for a schedule's `taskTemplate`. */
4728
+ const SCHEDULE_TEMPLATE_PREVIEW_LENGTH = 280;
4729
+ /** Preview length for a task's `task` text (pool-triage needs to read it). */
4730
+ const TASK_PREVIEW_LENGTH = 300;
4731
+
4732
+ /** Truncate text for a list-row preview. Appends an ellipsis when clipped. */
4733
+ function previewText(text: string | null | undefined, maxChars: number): string {
4734
+ const s = text ?? "";
4735
+ return s.length > maxChars ? `${s.slice(0, maxChars)}…` : s;
4736
+ }
4737
+
4480
4738
  function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
4481
4739
  return {
4482
4740
  id: row.id,
@@ -4511,7 +4769,28 @@ export interface ScheduledTaskFilters {
4511
4769
  hideCompleted?: boolean;
4512
4770
  }
4513
4771
 
4514
- export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask[] {
4772
+ /**
4773
+ * Slim list-row mapper — replaces the full `taskTemplate` (the per-run prompt,
4774
+ * avg ~3.6 KB) with a bounded `taskTemplatePreview`. Fetch the full template
4775
+ * via `getScheduledTaskById(id)`.
4776
+ */
4777
+ function rowToScheduledTaskSummary(row: ScheduledTaskRow): ScheduledTaskSummary {
4778
+ const { taskTemplate, ...rest } = rowToScheduledTask(row);
4779
+ return {
4780
+ ...rest,
4781
+ taskTemplatePreview: previewText(taskTemplate, SCHEDULE_TEMPLATE_PREVIEW_LENGTH),
4782
+ };
4783
+ }
4784
+
4785
+ export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask[];
4786
+ export function getScheduledTasks(
4787
+ filters: ScheduledTaskFilters | undefined,
4788
+ opts: { slim: true },
4789
+ ): ScheduledTaskSummary[];
4790
+ export function getScheduledTasks(
4791
+ filters?: ScheduledTaskFilters,
4792
+ opts?: { slim?: boolean },
4793
+ ): ScheduledTask[] | ScheduledTaskSummary[] {
4515
4794
  let query = "SELECT * FROM scheduled_tasks WHERE 1=1";
4516
4795
  const params: (string | number)[] = [];
4517
4796
 
@@ -4536,10 +4815,10 @@ export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask
4536
4815
 
4537
4816
  query += " ORDER BY name ASC";
4538
4817
 
4539
- return getDb()
4818
+ const rows = getDb()
4540
4819
  .prepare<ScheduledTaskRow, (string | number)[]>(query)
4541
- .all(...params)
4542
- .map(rowToScheduledTask);
4820
+ .all(...params);
4821
+ return opts?.slim ? rows.map(rowToScheduledTaskSummary) : rows.map(rowToScheduledTask);
4543
4822
  }
4544
4823
 
4545
4824
  export function getScheduledTaskById(id: string): ScheduledTask | null {
@@ -5580,7 +5859,7 @@ export function getIdleWorkersWithCapacity(): Agent[] {
5580
5859
  WHERE status = 'idle' AND isLead = 0`,
5581
5860
  )
5582
5861
  .all()
5583
- .map(rowToAgent);
5862
+ .map((row) => rowToAgent(row));
5584
5863
 
5585
5864
  return agents.filter((agent) => {
5586
5865
  const activeCount = getActiveTaskCount(agent.id);
@@ -5739,7 +6018,43 @@ export function getWorkflow(id: string): Workflow | null {
5739
6018
  return row ? rowToWorkflow(row) : null;
5740
6019
  }
5741
6020
 
5742
- export function listWorkflows(filters?: { enabled?: boolean }): Workflow[] {
6021
+ /**
6022
+ * Slim list-row mapper — drops the heavy `definition` (avg ~18 KB/row) and the
6023
+ * trigger config, keeping a derived `nodeCount` so the list view can still
6024
+ * answer "how big is this workflow" without the full DAG. Fetch the full shape
6025
+ * via `getWorkflow(id)`.
6026
+ */
6027
+ function rowToWorkflowSummary(row: WorkflowRow): WorkflowSummary {
6028
+ let nodeCount = 0;
6029
+ try {
6030
+ const def = JSON.parse(row.definition) as WorkflowDefinition;
6031
+ nodeCount = Array.isArray(def?.nodes) ? def.nodes.length : 0;
6032
+ } catch {
6033
+ nodeCount = 0;
6034
+ }
6035
+ return {
6036
+ id: row.id,
6037
+ name: row.name,
6038
+ description: row.description ?? undefined,
6039
+ enabled: row.enabled === 1,
6040
+ dir: row.dir ?? undefined,
6041
+ vcsRepo: row.vcs_repo ?? undefined,
6042
+ createdByAgentId: row.createdByAgentId ?? undefined,
6043
+ createdAt: normalizeDateRequired(row.createdAt),
6044
+ lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
6045
+ nodeCount,
6046
+ };
6047
+ }
6048
+
6049
+ export function listWorkflows(filters?: { enabled?: boolean }): Workflow[];
6050
+ export function listWorkflows(
6051
+ filters: { enabled?: boolean } | undefined,
6052
+ opts: { slim: true },
6053
+ ): WorkflowSummary[];
6054
+ export function listWorkflows(
6055
+ filters?: { enabled?: boolean },
6056
+ opts?: { slim?: boolean },
6057
+ ): Workflow[] | WorkflowSummary[] {
5743
6058
  let query = "SELECT * FROM workflows WHERE 1=1";
5744
6059
  const params: (string | number)[] = [];
5745
6060
  if (filters?.enabled !== undefined) {
@@ -5747,10 +6062,10 @@ export function listWorkflows(filters?: { enabled?: boolean }): Workflow[] {
5747
6062
  params.push(filters.enabled ? 1 : 0);
5748
6063
  }
5749
6064
  query += " ORDER BY name ASC";
5750
- return getDb()
6065
+ const rows = getDb()
5751
6066
  .prepare<WorkflowRow, (string | number)[]>(query)
5752
- .all(...params)
5753
- .map(rowToWorkflow);
6067
+ .all(...params);
6068
+ return opts?.slim ? rows.map(rowToWorkflowSummary) : rows.map(rowToWorkflow);
5754
6069
  }
5755
6070
 
5756
6071
  export function updateWorkflow(
@@ -6378,22 +6693,84 @@ export function getPageBySlug(agentId: string, slug: string): Page | null {
6378
6693
  return row ? rowToPage(row) : null;
6379
6694
  }
6380
6695
 
6381
- export function listPagesByAgent(agentId: string, limit = 100, offset = 0): Page[] {
6382
- return getDb()
6696
+ /**
6697
+ * Slim list-row mapper — drops the page `body` (the full HTML/JSON document,
6698
+ * up to ~290 KB and ~95% of a list payload) and `passwordHash`. Fetch the
6699
+ * full page via `getPage(id)`.
6700
+ */
6701
+ function rowToPageSummary(row: PageRow): PageSummary {
6702
+ return {
6703
+ id: row.id,
6704
+ agentId: row.agentId,
6705
+ slug: row.slug,
6706
+ title: row.title,
6707
+ description: row.description ?? undefined,
6708
+ contentType: row.contentType as PageContentType,
6709
+ authMode: row.authMode as PageAuthMode,
6710
+ needsCredentials: row.needsCredentials
6711
+ ? (JSON.parse(row.needsCredentials) as string[])
6712
+ : undefined,
6713
+ viewCount: typeof row.view_count === "number" ? row.view_count : 0,
6714
+ createdAt: normalizeDateRequired(row.createdAt),
6715
+ updatedAt: normalizeDateRequired(row.updatedAt),
6716
+ };
6717
+ }
6718
+
6719
+ export function listPagesByAgent(agentId: string, limit?: number, offset?: number): Page[];
6720
+ export function listPagesByAgent(
6721
+ agentId: string,
6722
+ limit: number | undefined,
6723
+ offset: number | undefined,
6724
+ opts: { slim: true },
6725
+ ): PageSummary[];
6726
+ export function listPagesByAgent(
6727
+ agentId: string,
6728
+ limit = 100,
6729
+ offset = 0,
6730
+ opts?: { slim?: boolean },
6731
+ ): Page[] | PageSummary[] {
6732
+ const rows = getDb()
6383
6733
  .prepare<PageRow, [string, number, number]>(
6384
6734
  "SELECT * FROM pages WHERE agentId = ? ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6385
6735
  )
6386
- .all(agentId, limit, offset)
6387
- .map(rowToPage);
6388
- }
6389
-
6390
- export function listAllPages(limit = 100, offset = 0): Page[] {
6391
- return getDb()
6736
+ .all(agentId, limit, offset);
6737
+ return opts?.slim ? rows.map(rowToPageSummary) : rows.map(rowToPage);
6738
+ }
6739
+
6740
+ export function listAllPages(limit?: number, offset?: number): Page[];
6741
+ export function listAllPages(
6742
+ limit: number | undefined,
6743
+ offset: number | undefined,
6744
+ opts: { slim: true },
6745
+ ): PageSummary[];
6746
+ export function listAllPages(
6747
+ limit = 100,
6748
+ offset = 0,
6749
+ opts?: { slim?: boolean },
6750
+ ): Page[] | PageSummary[] {
6751
+ const rows = getDb()
6392
6752
  .prepare<PageRow, [number, number]>(
6393
6753
  "SELECT * FROM pages ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6394
6754
  )
6395
- .all(limit, offset)
6396
- .map(rowToPage);
6755
+ .all(limit, offset);
6756
+ return opts?.slim ? rows.map(rowToPageSummary) : rows.map(rowToPage);
6757
+ }
6758
+
6759
+ /**
6760
+ * Total page count — used to back a filter-aware `total` in the `/api/pages`
6761
+ * pager so the UI shows the real count, not just the current page's length.
6762
+ */
6763
+ export function countAllPages(): number {
6764
+ const row = getDb().prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM pages").get();
6765
+ return row?.count ?? 0;
6766
+ }
6767
+
6768
+ /** Page count scoped to a single agent — companion to `listPagesByAgent`. */
6769
+ export function countPagesByAgent(agentId: string): number {
6770
+ const row = getDb()
6771
+ .prepare<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM pages WHERE agentId = ?")
6772
+ .get(agentId);
6773
+ return row?.count ?? 0;
6397
6774
  }
6398
6775
 
6399
6776
  /**
@@ -7842,11 +8219,16 @@ export interface SkillFilters {
7842
8219
  includeContent?: boolean;
7843
8220
  }
7844
8221
 
8222
+ /**
8223
+ * Explicit column list used when `includeContent: false` — selects every
8224
+ * skill column except the heavy `content` (the full SKILL.md, avg ~10 KB),
8225
+ * which is replaced with an empty string so the row still satisfies `Skill`.
8226
+ */
8227
+ const SKILL_SLIM_COLUMNS =
8228
+ "id, name, description, type, scope, ownerAgentId, sourceUrl, sourceRepo, sourcePath, sourceBranch, sourceHash, isComplex, allowedTools, model, effort, context, agent, disableModelInvocation, userInvocable, version, isEnabled, createdAt, lastUpdatedAt, lastFetchedAt, '' as content";
8229
+
7845
8230
  export function listSkills(filters?: SkillFilters): Skill[] {
7846
- const columns =
7847
- filters?.includeContent === false
7848
- ? "id, name, description, type, scope, ownerAgentId, sourceUrl, sourceRepo, sourcePath, sourceBranch, sourceHash, isComplex, allowedTools, model, effort, context, agent, disableModelInvocation, userInvocable, version, isEnabled, createdAt, lastUpdatedAt, lastFetchedAt, '' as content"
7849
- : "*";
8231
+ const columns = filters?.includeContent === false ? SKILL_SLIM_COLUMNS : "*";
7850
8232
  let query = `SELECT ${columns} FROM skills WHERE 1=1`;
7851
8233
  const params: (string | number)[] = [];
7852
8234
 
@@ -7885,11 +8267,12 @@ export function listSkills(filters?: SkillFilters): Skill[] {
7885
8267
  .map(rowToSkill);
7886
8268
  }
7887
8269
 
7888
- export function searchSkills(query: string, limit = 20): Skill[] {
8270
+ export function searchSkills(query: string, limit = 20, includeContent = true): Skill[] {
7889
8271
  const term = `%${query}%`;
8272
+ const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
7890
8273
  return getDb()
7891
8274
  .prepare<SkillRow, [string, string, number]>(
7892
- "SELECT * FROM skills WHERE (name LIKE ? OR description LIKE ?) AND isEnabled = 1 ORDER BY name ASC LIMIT ?",
8275
+ `SELECT ${columns} FROM skills WHERE (name LIKE ? OR description LIKE ?) AND isEnabled = 1 ORDER BY name ASC LIMIT ?`,
7893
8276
  )
7894
8277
  .all(term, term, limit)
7895
8278
  .map(rowToSkill);
@@ -9094,27 +9477,56 @@ export interface SessionListItem {
9094
9477
  latestStatus: AgentTaskStatus;
9095
9478
  }
9096
9479
 
9480
+ /**
9481
+ * Slim variant of {@link SessionListItem} — the `root` task is an
9482
+ * `AgentTaskSummary` (full `task` text + completion/integration blobs dropped).
9483
+ * The session list only renders a brief of the root; the full root + chain are
9484
+ * on `GET /api/sessions/{rootTaskId}`.
9485
+ */
9486
+ export interface SessionListItemSummary {
9487
+ root: AgentTaskSummary;
9488
+ chainTaskCount: number;
9489
+ lastActivityAt: string;
9490
+ latestStatus: AgentTaskStatus;
9491
+ }
9492
+
9097
9493
  /**
9098
9494
  * List the most recent sessions ordered by chain-wide latest activity.
9099
9495
  * A "session" here is any task with `parentTaskId IS NULL` — its descendants
9100
9496
  * (children, grand-children, …) are summarized via the recursive CTE.
9101
9497
  *
9102
- * `lastActivityAt` is `MAX(t.lastUpdatedAt)` over the entire chain rooted at
9103
- * the candidate task, computed as a correlated subquery so the outer ORDER
9104
- * BY can sort against it.
9498
+ * Single-pass CTE: seeds with root tasks matching the filter, walks the full
9499
+ * descendant tree once, then aggregates chainCount / lastActivityAt /
9500
+ * latestStatus in two lightweight non-recursive CTEs — replacing the original
9501
+ * pattern of 3 correlated subqueries each re-running the recursion per row.
9105
9502
  */
9106
- export function listRecentSessions(opts?: {
9503
+ interface ListRecentSessionsOpts {
9107
9504
  limit?: number;
9108
9505
  offset?: number;
9109
9506
  /** Filter to root tasks whose `source` is in this list. Empty/undefined → no source filter. */
9110
9507
  source?: string[];
9111
9508
  /** Case-insensitive substring match against `r.task`. */
9112
9509
  q?: string;
9113
- }): SessionListItem[] {
9510
+ /** When set, restrict to root tasks where `requestedByUserId` equals this value. NULL rows are excluded. */
9511
+ requestedByUserId?: string;
9512
+ /** When true, return slim `SessionListItemSummary` rows (default: full). */
9513
+ slim?: boolean;
9514
+ }
9515
+
9516
+ export function listRecentSessions(
9517
+ opts?: ListRecentSessionsOpts & { slim?: false },
9518
+ ): SessionListItem[];
9519
+ export function listRecentSessions(
9520
+ opts: ListRecentSessionsOpts & { slim: true },
9521
+ ): SessionListItemSummary[];
9522
+ export function listRecentSessions(
9523
+ opts?: ListRecentSessionsOpts,
9524
+ ): SessionListItem[] | SessionListItemSummary[] {
9114
9525
  const limit = opts?.limit ?? 25;
9115
9526
  const offset = opts?.offset ?? 0;
9116
9527
  const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
9117
9528
  const q = opts?.q?.trim();
9529
+ const requestedByUserId = opts?.requestedByUserId?.trim() || undefined;
9118
9530
 
9119
9531
  const conditions: string[] = ["r.parentTaskId IS NULL"];
9120
9532
  const params: (string | number)[] = [];
@@ -9127,6 +9539,10 @@ export function listRecentSessions(opts?: {
9127
9539
  conditions.push("lower(r.task) LIKE ?");
9128
9540
  params.push(`%${q.toLowerCase()}%`);
9129
9541
  }
9542
+ if (requestedByUserId) {
9543
+ conditions.push("r.requestedByUserId = ?");
9544
+ params.push(requestedByUserId);
9545
+ }
9130
9546
  params.push(limit, offset);
9131
9547
 
9132
9548
  const rootRows = getDb()
@@ -9134,48 +9550,55 @@ export function listRecentSessions(opts?: {
9134
9550
  AgentTaskRow & { __chainCount: number; __lastActivityAt: string; __latestStatus: string },
9135
9551
  typeof params
9136
9552
  >(
9137
- `SELECT
9553
+ `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status) AS (
9554
+ SELECT r.id, r.id, r.lastUpdatedAt, r.status
9555
+ FROM agent_tasks r
9556
+ WHERE ${conditions.join(" AND ")}
9557
+ UNION ALL
9558
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status
9559
+ FROM agent_tasks t
9560
+ JOIN chain c ON t.parentTaskId = c.id
9561
+ ),
9562
+ agg AS (
9563
+ SELECT
9564
+ root_id,
9565
+ COUNT(*) AS chainCount,
9566
+ MAX(lastUpdatedAt) AS lastActivityAt
9567
+ FROM chain
9568
+ GROUP BY root_id
9569
+ ),
9570
+ latest_status AS (
9571
+ SELECT c.root_id, c.status AS latestStatus
9572
+ FROM chain c
9573
+ JOIN agg a ON c.root_id = a.root_id AND c.lastUpdatedAt = a.lastActivityAt
9574
+ GROUP BY c.root_id
9575
+ )
9576
+ SELECT
9138
9577
  r.*,
9139
- (SELECT COUNT(*) FROM agent_tasks d
9140
- WHERE d.id IN (
9141
- WITH RECURSIVE chain(id) AS (
9142
- SELECT r.id
9143
- UNION ALL
9144
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9145
- )
9146
- SELECT id FROM chain
9147
- )
9148
- ) AS __chainCount,
9149
- (SELECT MAX(d.lastUpdatedAt) FROM agent_tasks d
9150
- WHERE d.id IN (
9151
- WITH RECURSIVE chain(id) AS (
9152
- SELECT r.id
9153
- UNION ALL
9154
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9155
- )
9156
- SELECT id FROM chain
9157
- )
9158
- ) AS __lastActivityAt,
9159
- (SELECT d.status FROM agent_tasks d
9160
- WHERE d.id IN (
9161
- WITH RECURSIVE chain(id) AS (
9162
- SELECT r.id
9163
- UNION ALL
9164
- SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
9165
- )
9166
- SELECT id FROM chain
9167
- )
9168
- ORDER BY d.lastUpdatedAt DESC
9169
- LIMIT 1
9170
- ) AS __latestStatus
9578
+ a.chainCount AS __chainCount,
9579
+ a.lastActivityAt AS __lastActivityAt,
9580
+ COALESCE(ls.latestStatus, r.status) AS __latestStatus
9171
9581
  FROM agent_tasks r
9172
- WHERE ${conditions.join(" AND ")}
9173
- ORDER BY __lastActivityAt DESC
9582
+ JOIN agg a ON a.root_id = r.id
9583
+ LEFT JOIN latest_status ls ON ls.root_id = r.id
9584
+ ORDER BY a.lastActivityAt DESC
9174
9585
  LIMIT ? OFFSET ?`,
9175
9586
  )
9176
9587
  .all(...params);
9177
9588
 
9178
- return rootRows.map((row) => {
9589
+ if (opts?.slim) {
9590
+ return rootRows.map((row): SessionListItemSummary => {
9591
+ const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
9592
+ return {
9593
+ root: rowToAgentTaskSummary(taskRow as AgentTaskRow),
9594
+ chainTaskCount: __chainCount,
9595
+ lastActivityAt: __lastActivityAt ?? row.lastUpdatedAt,
9596
+ latestStatus: (__latestStatus as AgentTaskStatus) ?? row.status,
9597
+ };
9598
+ });
9599
+ }
9600
+
9601
+ return rootRows.map((row): SessionListItem => {
9179
9602
  const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
9180
9603
  return {
9181
9604
  root: rowToAgentTask(taskRow as AgentTaskRow),
@@ -9186,6 +9609,43 @@ export function listRecentSessions(opts?: {
9186
9609
  });
9187
9610
  }
9188
9611
 
9612
+ /**
9613
+ * Filter-aware count of sessions (root tasks) matching the same `source` / `q`
9614
+ * / `requestedByUserId` filters as `listRecentSessions`. Powers a correct
9615
+ * `total` in the `/api/sessions` pager — a session is a root task, so this is
9616
+ * a plain count, no recursive chain walk needed.
9617
+ */
9618
+ export function countSessions(
9619
+ opts?: Pick<ListRecentSessionsOpts, "source" | "q" | "requestedByUserId">,
9620
+ ): number {
9621
+ const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
9622
+ const q = opts?.q?.trim();
9623
+ const requestedByUserId = opts?.requestedByUserId?.trim() || undefined;
9624
+
9625
+ const conditions: string[] = ["parentTaskId IS NULL"];
9626
+ const params: string[] = [];
9627
+
9628
+ if (sources.length > 0) {
9629
+ conditions.push(`source IN (${sources.map(() => "?").join(", ")})`);
9630
+ params.push(...sources);
9631
+ }
9632
+ if (q && q.length > 0) {
9633
+ conditions.push("lower(task) LIKE ?");
9634
+ params.push(`%${q.toLowerCase()}%`);
9635
+ }
9636
+ if (requestedByUserId) {
9637
+ conditions.push("requestedByUserId = ?");
9638
+ params.push(requestedByUserId);
9639
+ }
9640
+
9641
+ const row = getDb()
9642
+ .prepare<{ count: number }, string[]>(
9643
+ `SELECT COUNT(*) AS count FROM agent_tasks WHERE ${conditions.join(" AND ")}`,
9644
+ )
9645
+ .get(...params);
9646
+ return row?.count ?? 0;
9647
+ }
9648
+
9189
9649
  // ============================================================================
9190
9650
  // Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
9191
9651
  // ----------------------------------------------------------------------------
@@ -9678,6 +10138,60 @@ export function getInstanceActivity(): {
9678
10138
  };
9679
10139
  }
9680
10140
 
10141
+ export interface SwarmMetrics {
10142
+ tasks: { total: number; by_status: Record<string, number> };
10143
+ agents: { total: number; by_status: Record<string, number> };
10144
+ workflows: { total: number; enabled: number };
10145
+ pages: { total: number };
10146
+ sessions: { active: number };
10147
+ skills: { total: number };
10148
+ }
10149
+
10150
+ /**
10151
+ * Lightweight swarm-wide counts for UI footers/sidebars and MCP context —
10152
+ * a single object so callers never have to fetch full list payloads just to
10153
+ * count. Pure `COUNT(*)` / `GROUP BY` queries; the `agent_tasks` status
10154
+ * grouping rides the indexes added in migration 069.
10155
+ */
10156
+ export function getSwarmMetrics(): SwarmMetrics {
10157
+ const db = getDb();
10158
+
10159
+ const groupCounts = (table: string): { total: number; by_status: Record<string, number> } => {
10160
+ const rows = db
10161
+ .prepare<{ status: string; count: number }, []>(
10162
+ `SELECT status, COUNT(*) AS count FROM ${table} GROUP BY status`,
10163
+ )
10164
+ .all();
10165
+ const by_status: Record<string, number> = {};
10166
+ let total = 0;
10167
+ for (const r of rows) {
10168
+ by_status[r.status] = r.count;
10169
+ total += r.count;
10170
+ }
10171
+ return { total, by_status };
10172
+ };
10173
+
10174
+ const workflowRow = db
10175
+ .prepare<{ total: number; enabled: number }, []>(
10176
+ "SELECT COUNT(*) AS total, SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled FROM workflows",
10177
+ )
10178
+ .get();
10179
+ const pagesRow = db.prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM pages").get();
10180
+ const sessionsRow = db
10181
+ .prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM active_sessions")
10182
+ .get();
10183
+ const skillsRow = db.prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM skills").get();
10184
+
10185
+ return {
10186
+ tasks: groupCounts("agent_tasks"),
10187
+ agents: groupCounts("agents"),
10188
+ workflows: { total: workflowRow?.total ?? 0, enabled: workflowRow?.enabled ?? 0 },
10189
+ pages: { total: pagesRow?.count ?? 0 },
10190
+ sessions: { active: sessionsRow?.count ?? 0 },
10191
+ skills: { total: skillsRow?.count ?? 0 },
10192
+ };
10193
+ }
10194
+
9681
10195
  /**
9682
10196
  * `first_task` milestone: true once any task has reached `status = 'completed'`.
9683
10197
  * Cheap LIMIT 1 probe; the row's contents don't matter, only existence.