@desplega.ai/agent-swarm 1.75.0 → 1.76.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 (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.75.0",
3
+ "version": "1.76.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -108,7 +108,7 @@
108
108
  "@mariozechner/pi-ai": "^0.73.0",
109
109
  "@mariozechner/pi-coding-agent": "^0.73.0",
110
110
  "@modelcontextprotocol/sdk": "^1.25.1",
111
- "@openai/codex-sdk": "^0.125.0",
111
+ "@openai/codex-sdk": "^0.128.0",
112
112
  "@opencode-ai/sdk": "^1.14.30",
113
113
  "@openfort/openfort-node": "^0.9.1",
114
114
  "@slack/bolt": "^4.6.0",
package/src/be/db.ts CHANGED
@@ -6,6 +6,7 @@ import { configureDbResolver } from "../prompts/resolver";
6
6
  import type {
7
7
  ActiveSession,
8
8
  Agent,
9
+ AgentCredStatus,
9
10
  AgentLog,
10
11
  AgentLogEventType,
11
12
  AgentMcpServer,
@@ -27,7 +28,9 @@ import type {
27
28
  ContextSnapshotEventType,
28
29
  ContextVersion,
29
30
  CooldownConfig,
30
- DevinProviderMeta,
31
+ InboxItemState,
32
+ InboxItemStatus,
33
+ InboxItemType,
31
34
  InboxMessage,
32
35
  InboxMessageStatus,
33
36
  InputValue,
@@ -54,6 +57,8 @@ import type {
54
57
  SkillWithInstallInfo,
55
58
  SwarmConfig,
56
59
  SwarmRepo,
60
+ TaskTemplate,
61
+ TaskTemplateKind,
57
62
  TriggerConfig,
58
63
  User,
59
64
  VersionableField,
@@ -557,6 +562,10 @@ type AgentRow = {
557
562
  lastUpdatedAt: string;
558
563
  /** JSON array of env-var names; populated only when status is `waiting_for_credentials`. */
559
564
  credentialMissing: string | null;
565
+ /** Phase 1.5: per-agent harness provider pushed on worker registration. */
566
+ harness_provider: string | null;
567
+ /** Migration 055: worker-self-reported credential snapshot (JSON of AgentCredStatus). NULL = unreported. */
568
+ cred_status: string | null;
560
569
  };
561
570
 
562
571
  function rowToAgent(row: AgentRow): Agent {
@@ -578,18 +587,23 @@ function rowToAgent(row: AgentRow): Agent {
578
587
  heartbeatMd: row.heartbeatMd ?? undefined,
579
588
  lastActivityAt: row.lastActivityAt ?? undefined,
580
589
  provider: (row.provider as ProviderName | null) ?? undefined,
590
+ harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
581
591
  createdAt: row.createdAt,
582
592
  lastUpdatedAt: row.lastUpdatedAt,
583
593
  credentialMissing: row.credentialMissing
584
594
  ? (JSON.parse(row.credentialMissing) as string[])
585
595
  : null,
596
+ credStatus: row.cred_status ? (JSON.parse(row.cred_status) as AgentCredStatus) : null,
586
597
  };
587
598
  }
588
599
 
589
600
  export const agentQueries = {
590
601
  insert: () =>
591
- getDb().prepare<AgentRow, [string, string, number, AgentStatus, number, string | null]>(
592
- "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
602
+ getDb().prepare<
603
+ AgentRow,
604
+ [string, string, number, AgentStatus, number, string | null, string | null]
605
+ >(
606
+ "INSERT INTO agents (id, name, isLead, status, maxTasks, provider, harness_provider, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
593
607
  ),
594
608
 
595
609
  getById: () => getDb().prepare<AgentRow, [string]>("SELECT * FROM agents WHERE id = ?"),
@@ -638,7 +652,15 @@ export function createAgent(
638
652
  const maxTasks = agent.maxTasks ?? 1;
639
653
  const row = agentQueries
640
654
  .insert()
641
- .get(id, agent.name, agent.isLead ? 1 : 0, agent.status, maxTasks, agent.provider ?? null);
655
+ .get(
656
+ id,
657
+ agent.name,
658
+ agent.isLead ? 1 : 0,
659
+ agent.status,
660
+ maxTasks,
661
+ agent.provider ?? null,
662
+ agent.harnessProvider ?? null,
663
+ );
642
664
  if (!row) throw new Error("Failed to create agent");
643
665
  try {
644
666
  createLogEntry({ eventType: "agent_joined", agentId: id, newValue: agent.status });
@@ -696,6 +718,83 @@ export function updateAgentProvider(id: string, provider: ProviderName): Agent |
696
718
  return row ? rowToAgent(row) : null;
697
719
  }
698
720
 
721
+ /**
722
+ * Phase 1.5 (cloud-personalization): set the per-agent `harness_provider`
723
+ * column. Pass `null` to clear. Validation against the canonical provider
724
+ * list happens at the API layer via `ProviderNameSchema`.
725
+ *
726
+ * Returns the updated row, or null if the agent does not exist.
727
+ */
728
+ export function setAgentHarnessProvider(id: string, provider: ProviderName | null): Agent | null {
729
+ const row = getDb()
730
+ .prepare<AgentRow, [string | null, string]>(
731
+ `UPDATE agents SET harness_provider = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
732
+ WHERE id = ? RETURNING *`,
733
+ )
734
+ .get(provider, id);
735
+ return row ? rowToAgent(row) : null;
736
+ }
737
+
738
+ /**
739
+ * Migration 055 — write the worker-self-reported credential snapshot.
740
+ * Pass `null` to clear (e.g. on agent re-registration). Validation against
741
+ * the JSON shape happens at the API layer via `AgentCredStatusSchema`.
742
+ *
743
+ * Worker reports this alongside the existing `updateAgentCredentialState`
744
+ * call; we keep the writes in two functions so the dispatch pattern stays
745
+ * one-row-one-fact, and the PATCH handler can choose which to call based
746
+ * on which fields the request body carried.
747
+ */
748
+ export function updateAgentCredStatus(
749
+ id: string,
750
+ credStatus: AgentCredStatus | null,
751
+ ): Agent | null {
752
+ const json = credStatus ? JSON.stringify(credStatus) : null;
753
+ const row = getDb()
754
+ .prepare<AgentRow, [string | null, string]>(
755
+ `UPDATE agents SET cred_status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
756
+ WHERE id = ? RETURNING *`,
757
+ )
758
+ .get(json, id);
759
+ return row ? rowToAgent(row) : null;
760
+ }
761
+
762
+ /**
763
+ * Migration 055 — read all agents whose `harness_provider` matches a given
764
+ * provider, with their reported `cred_status`. Used by the credential-status
765
+ * API endpoint to roll up "is this provider working across the fleet?".
766
+ *
767
+ * Agents with NULL `cred_status` (never reported, or CRED_CHECK_DISABLE=1)
768
+ * are still returned — the caller surfaces them as "unreported".
769
+ */
770
+ export function listAgentsWithCredStatusByProvider(provider: string): Agent[] {
771
+ const rows = getDb()
772
+ .prepare<AgentRow, [string]>(`SELECT * FROM agents WHERE harness_provider = ? ORDER BY name`)
773
+ .all(provider);
774
+ return rows.map(rowToAgent);
775
+ }
776
+
777
+ /**
778
+ * Phase 1.5 (cloud-personalization): aggregate count of registered agents
779
+ * by `harness_provider`. NULL rows (agents that registered before the
780
+ * migration or never pushed a value) are excluded — they show up in the
781
+ * total agent count but not here.
782
+ *
783
+ * Used by future fleet displays. Not consumed in this phase.
784
+ */
785
+ export function getAgentHarnessProviders(): Array<{ provider: string; count: number }> {
786
+ const rows = getDb()
787
+ .prepare<{ provider: string; count: number }, []>(
788
+ `SELECT harness_provider AS provider, COUNT(*) AS count
789
+ FROM agents
790
+ WHERE harness_provider IS NOT NULL
791
+ GROUP BY harness_provider
792
+ ORDER BY harness_provider`,
793
+ )
794
+ .all();
795
+ return rows.map((r) => ({ provider: r.provider, count: r.count }));
796
+ }
797
+
699
798
  export function updateAgentActivity(id: string): void {
700
799
  getDb()
701
800
  .prepare<null, [string]>(
@@ -1222,7 +1321,8 @@ export function findTaskByVcs(vcsRepo: string, vcsNumber: number): AgentTask | n
1222
1321
  export const findTaskByGitHub = findTaskByVcs;
1223
1322
 
1224
1323
  export interface TaskFilters {
1225
- status?: AgentTaskStatus;
1324
+ /** Single status (back-compat) OR array of statuses (multi-status filter). */
1325
+ status?: AgentTaskStatus | AgentTaskStatus[];
1226
1326
  agentId?: string;
1227
1327
  search?: string;
1228
1328
  // New filters
@@ -1232,6 +1332,10 @@ export interface TaskFilters {
1232
1332
  taskType?: string;
1233
1333
  tags?: string[];
1234
1334
  scheduleId?: string;
1335
+ /** Filter to tasks whose `source` is in this list. Empty/undefined → no filter. */
1336
+ source?: AgentTaskSource[];
1337
+ /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
1338
+ createdAfter?: string;
1235
1339
  limit?: number;
1236
1340
  offset?: number;
1237
1341
  includeHeartbeat?: boolean;
@@ -1242,8 +1346,19 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1242
1346
  const params: (string | AgentTaskStatus)[] = [];
1243
1347
 
1244
1348
  if (filters?.status) {
1245
- conditions.push("status = ?");
1246
- params.push(filters.status);
1349
+ if (Array.isArray(filters.status)) {
1350
+ if (filters.status.length === 1) {
1351
+ conditions.push("status = ?");
1352
+ params.push(filters.status[0]!);
1353
+ } else if (filters.status.length > 1) {
1354
+ const placeholders = filters.status.map(() => "?").join(", ");
1355
+ conditions.push(`status IN (${placeholders})`);
1356
+ for (const s of filters.status) params.push(s);
1357
+ }
1358
+ } else {
1359
+ conditions.push("status = ?");
1360
+ params.push(filters.status);
1361
+ }
1247
1362
  }
1248
1363
 
1249
1364
  if (filters?.agentId) {
@@ -1285,6 +1400,17 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1285
1400
  params.push(filters.scheduleId);
1286
1401
  }
1287
1402
 
1403
+ if (filters?.source && filters.source.length > 0) {
1404
+ const placeholders = filters.source.map(() => "?").join(", ");
1405
+ conditions.push(`source IN (${placeholders})`);
1406
+ for (const s of filters.source) params.push(s);
1407
+ }
1408
+
1409
+ if (filters?.createdAfter) {
1410
+ conditions.push("createdAt >= ?");
1411
+ params.push(filters.createdAfter);
1412
+ }
1413
+
1288
1414
  // Exclude heartbeat tasks by default
1289
1415
  if (!filters?.includeHeartbeat) {
1290
1416
  conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
@@ -1320,8 +1446,19 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1320
1446
  const params: (string | AgentTaskStatus)[] = [];
1321
1447
 
1322
1448
  if (filters?.status) {
1323
- conditions.push("status = ?");
1324
- params.push(filters.status);
1449
+ if (Array.isArray(filters.status)) {
1450
+ if (filters.status.length === 1) {
1451
+ conditions.push("status = ?");
1452
+ params.push(filters.status[0]!);
1453
+ } else if (filters.status.length > 1) {
1454
+ const placeholders = filters.status.map(() => "?").join(", ");
1455
+ conditions.push(`status IN (${placeholders})`);
1456
+ for (const s of filters.status) params.push(s);
1457
+ }
1458
+ } else {
1459
+ conditions.push("status = ?");
1460
+ params.push(filters.status);
1461
+ }
1325
1462
  }
1326
1463
 
1327
1464
  if (filters?.agentId) {
@@ -1361,6 +1498,17 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1361
1498
  params.push(filters.scheduleId);
1362
1499
  }
1363
1500
 
1501
+ if (filters?.source && filters.source.length > 0) {
1502
+ const placeholders = filters.source.map(() => "?").join(", ");
1503
+ conditions.push(`source IN (${placeholders})`);
1504
+ for (const s of filters.source) params.push(s);
1505
+ }
1506
+
1507
+ if (filters?.createdAfter) {
1508
+ conditions.push("createdAt >= ?");
1509
+ params.push(filters.createdAfter);
1510
+ }
1511
+
1364
1512
  // Exclude heartbeat tasks by default
1365
1513
  if (!filters?.includeHeartbeat) {
1366
1514
  conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
@@ -8482,6 +8630,301 @@ export function deleteUser(id: string): boolean {
8482
8630
  return result.changes > 0;
8483
8631
  }
8484
8632
 
8633
+ // ============================================================================
8634
+ // Inbox Item State (per-user dismiss/snooze/done for action-items inbox)
8635
+ // ============================================================================
8636
+
8637
+ interface InboxItemStateRow {
8638
+ id: string;
8639
+ userId: string;
8640
+ itemType: string;
8641
+ itemId: string;
8642
+ status: string;
8643
+ snoozeUntil: string | null;
8644
+ dismissedAt: string | null;
8645
+ doneAt: string | null;
8646
+ createdAt: string;
8647
+ lastUpdatedAt: string;
8648
+ }
8649
+
8650
+ function rowToInboxItemState(row: InboxItemStateRow): InboxItemState {
8651
+ return {
8652
+ id: row.id,
8653
+ userId: row.userId,
8654
+ itemType: row.itemType as InboxItemType,
8655
+ itemId: row.itemId,
8656
+ status: row.status as InboxItemStatus,
8657
+ snoozeUntil: row.snoozeUntil ?? undefined,
8658
+ dismissedAt: row.dismissedAt ?? undefined,
8659
+ doneAt: row.doneAt ?? undefined,
8660
+ createdAt: row.createdAt,
8661
+ lastUpdatedAt: row.lastUpdatedAt,
8662
+ };
8663
+ }
8664
+
8665
+ export function listInboxState(opts: {
8666
+ userId: string;
8667
+ status?: InboxItemStatus;
8668
+ itemType?: InboxItemType;
8669
+ }): InboxItemState[] {
8670
+ const conditions: string[] = ["userId = ?"];
8671
+ const params: string[] = [opts.userId];
8672
+
8673
+ if (opts.status) {
8674
+ conditions.push("status = ?");
8675
+ params.push(opts.status);
8676
+ }
8677
+ if (opts.itemType) {
8678
+ conditions.push("itemType = ?");
8679
+ params.push(opts.itemType);
8680
+ }
8681
+
8682
+ const where = conditions.join(" AND ");
8683
+ return getDb()
8684
+ .prepare<InboxItemStateRow, string[]>(
8685
+ `SELECT * FROM inbox_item_state WHERE ${where} ORDER BY lastUpdatedAt DESC`,
8686
+ )
8687
+ .all(...params)
8688
+ .map(rowToInboxItemState);
8689
+ }
8690
+
8691
+ export function upsertInboxState(opts: {
8692
+ userId: string;
8693
+ itemType: InboxItemType;
8694
+ itemId: string;
8695
+ status: InboxItemStatus;
8696
+ snoozeUntil?: string;
8697
+ dismissedAt?: string;
8698
+ doneAt?: string;
8699
+ }): InboxItemState {
8700
+ const now = new Date().toISOString();
8701
+ // Auto-derive timestamps from status when not explicitly provided.
8702
+ const dismissedAt = opts.dismissedAt ?? (opts.status === "dismissed" ? now : null);
8703
+ const doneAt = opts.doneAt ?? (opts.status === "done" ? now : null);
8704
+ const snoozeUntil = opts.snoozeUntil ?? null;
8705
+
8706
+ // SQLite upsert via UNIQUE(userId, itemType, itemId).
8707
+ const row = getDb()
8708
+ .prepare<InboxItemStateRow, (string | null)[]>(
8709
+ `INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, createdAt, lastUpdatedAt)
8710
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
8711
+ ON CONFLICT(userId, itemType, itemId) DO UPDATE SET
8712
+ status = excluded.status,
8713
+ snoozeUntil = excluded.snoozeUntil,
8714
+ dismissedAt = excluded.dismissedAt,
8715
+ doneAt = excluded.doneAt,
8716
+ lastUpdatedAt = excluded.lastUpdatedAt
8717
+ RETURNING *`,
8718
+ )
8719
+ .get(
8720
+ opts.userId,
8721
+ opts.itemType,
8722
+ opts.itemId,
8723
+ opts.status,
8724
+ snoozeUntil,
8725
+ dismissedAt,
8726
+ doneAt,
8727
+ now,
8728
+ now,
8729
+ );
8730
+ if (!row) throw new Error("Failed to upsert inbox state");
8731
+ return rowToInboxItemState(row);
8732
+ }
8733
+
8734
+ // ============================================================================
8735
+ // Task Templates ("To start" bucket — polymorphic starters registry)
8736
+ // ============================================================================
8737
+
8738
+ interface TaskTemplateRow {
8739
+ id: string;
8740
+ title: string;
8741
+ description: string;
8742
+ prompt: string;
8743
+ kind: string;
8744
+ payload: string;
8745
+ category: string | null;
8746
+ tags: string;
8747
+ createdAt: string;
8748
+ }
8749
+
8750
+ function rowToTaskTemplate(row: TaskTemplateRow): TaskTemplate {
8751
+ let payload: Record<string, unknown> = {};
8752
+ try {
8753
+ payload = JSON.parse(row.payload);
8754
+ } catch {}
8755
+ let tags: string[] = [];
8756
+ try {
8757
+ tags = JSON.parse(row.tags);
8758
+ } catch {}
8759
+ return {
8760
+ id: row.id,
8761
+ title: row.title,
8762
+ description: row.description,
8763
+ prompt: row.prompt,
8764
+ kind: row.kind as TaskTemplateKind,
8765
+ payload,
8766
+ category: row.category ?? undefined,
8767
+ tags,
8768
+ createdAt: row.createdAt,
8769
+ };
8770
+ }
8771
+
8772
+ export function listTaskTemplates(opts?: {
8773
+ category?: string;
8774
+ kind?: TaskTemplateKind;
8775
+ query?: string;
8776
+ }): TaskTemplate[] {
8777
+ const conditions: string[] = [];
8778
+ const params: string[] = [];
8779
+
8780
+ if (opts?.category) {
8781
+ conditions.push("category = ?");
8782
+ params.push(opts.category);
8783
+ }
8784
+ if (opts?.kind) {
8785
+ conditions.push("kind = ?");
8786
+ params.push(opts.kind);
8787
+ }
8788
+ if (opts?.query && opts.query.trim().length > 0) {
8789
+ // Case-insensitive LIKE match against title OR description, single
8790
+ // parameter-bound WHERE clause to prevent injection.
8791
+ conditions.push("(LOWER(title) LIKE ? OR LOWER(description) LIKE ?)");
8792
+ const needle = `%${opts.query.toLowerCase()}%`;
8793
+ params.push(needle, needle);
8794
+ }
8795
+
8796
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
8797
+ return getDb()
8798
+ .prepare<TaskTemplateRow, string[]>(`SELECT * FROM task_templates ${where} ORDER BY createdAt`)
8799
+ .all(...params)
8800
+ .map(rowToTaskTemplate);
8801
+ }
8802
+
8803
+ // ============================================================================
8804
+ // Sessions — root task chain + recent-sessions list
8805
+ // ============================================================================
8806
+
8807
+ /**
8808
+ * Walk the parent→child chain rooted at `rootTaskId` via recursive CTE.
8809
+ * Returns the chain ordered by `createdAt` (so the root is first; siblings
8810
+ * appear in creation order; grand-children after their parents).
8811
+ */
8812
+ export function getRootTaskChain(rootTaskId: string): AgentTask[] {
8813
+ const rows = getDb()
8814
+ .prepare<AgentTaskRow, string>(
8815
+ `WITH RECURSIVE chain(id) AS (
8816
+ SELECT id FROM agent_tasks WHERE id = ?
8817
+ UNION ALL
8818
+ SELECT t.id FROM agent_tasks t
8819
+ JOIN chain c ON t.parentTaskId = c.id
8820
+ )
8821
+ SELECT t.* FROM agent_tasks t
8822
+ JOIN chain ON chain.id = t.id
8823
+ ORDER BY t.createdAt`,
8824
+ )
8825
+ .all(rootTaskId);
8826
+ return rows.map(rowToAgentTask);
8827
+ }
8828
+
8829
+ export interface SessionListItem {
8830
+ root: AgentTask;
8831
+ chainTaskCount: number;
8832
+ lastActivityAt: string;
8833
+ latestStatus: AgentTaskStatus;
8834
+ }
8835
+
8836
+ /**
8837
+ * List the most recent sessions ordered by chain-wide latest activity.
8838
+ * A "session" here is any task with `parentTaskId IS NULL` — its descendants
8839
+ * (children, grand-children, …) are summarized via the recursive CTE.
8840
+ *
8841
+ * `lastActivityAt` is `MAX(t.lastUpdatedAt)` over the entire chain rooted at
8842
+ * the candidate task, computed as a correlated subquery so the outer ORDER
8843
+ * BY can sort against it.
8844
+ */
8845
+ export function listRecentSessions(opts?: {
8846
+ limit?: number;
8847
+ offset?: number;
8848
+ /** Filter to root tasks whose `source` is in this list. Empty/undefined → no source filter. */
8849
+ source?: string[];
8850
+ /** Case-insensitive substring match against `r.task`. */
8851
+ q?: string;
8852
+ }): SessionListItem[] {
8853
+ const limit = opts?.limit ?? 25;
8854
+ const offset = opts?.offset ?? 0;
8855
+ const sources = opts?.source?.filter((s) => s.length > 0) ?? [];
8856
+ const q = opts?.q?.trim();
8857
+
8858
+ const conditions: string[] = ["r.parentTaskId IS NULL"];
8859
+ const params: (string | number)[] = [];
8860
+
8861
+ if (sources.length > 0) {
8862
+ conditions.push(`r.source IN (${sources.map(() => "?").join(", ")})`);
8863
+ params.push(...sources);
8864
+ }
8865
+ if (q && q.length > 0) {
8866
+ conditions.push("lower(r.task) LIKE ?");
8867
+ params.push(`%${q.toLowerCase()}%`);
8868
+ }
8869
+ params.push(limit, offset);
8870
+
8871
+ const rootRows = getDb()
8872
+ .prepare<
8873
+ AgentTaskRow & { __chainCount: number; __lastActivityAt: string; __latestStatus: string },
8874
+ typeof params
8875
+ >(
8876
+ `SELECT
8877
+ r.*,
8878
+ (SELECT COUNT(*) FROM agent_tasks d
8879
+ WHERE d.id IN (
8880
+ WITH RECURSIVE chain(id) AS (
8881
+ SELECT r.id
8882
+ UNION ALL
8883
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8884
+ )
8885
+ SELECT id FROM chain
8886
+ )
8887
+ ) AS __chainCount,
8888
+ (SELECT MAX(d.lastUpdatedAt) FROM agent_tasks d
8889
+ WHERE d.id IN (
8890
+ WITH RECURSIVE chain(id) AS (
8891
+ SELECT r.id
8892
+ UNION ALL
8893
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8894
+ )
8895
+ SELECT id FROM chain
8896
+ )
8897
+ ) AS __lastActivityAt,
8898
+ (SELECT d.status FROM agent_tasks d
8899
+ WHERE d.id IN (
8900
+ WITH RECURSIVE chain(id) AS (
8901
+ SELECT r.id
8902
+ UNION ALL
8903
+ SELECT t.id FROM agent_tasks t JOIN chain c ON t.parentTaskId = c.id
8904
+ )
8905
+ SELECT id FROM chain
8906
+ )
8907
+ ORDER BY d.lastUpdatedAt DESC
8908
+ LIMIT 1
8909
+ ) AS __latestStatus
8910
+ FROM agent_tasks r
8911
+ WHERE ${conditions.join(" AND ")}
8912
+ ORDER BY __lastActivityAt DESC
8913
+ LIMIT ? OFFSET ?`,
8914
+ )
8915
+ .all(...params);
8916
+
8917
+ return rootRows.map((row) => {
8918
+ const { __chainCount, __lastActivityAt, __latestStatus, ...taskRow } = row;
8919
+ return {
8920
+ root: rowToAgentTask(taskRow as AgentTaskRow),
8921
+ chainTaskCount: __chainCount,
8922
+ lastActivityAt: __lastActivityAt ?? row.lastUpdatedAt,
8923
+ latestStatus: (__latestStatus as AgentTaskStatus) ?? row.status,
8924
+ };
8925
+ });
8926
+ }
8927
+
8485
8928
  // ============================================================================
8486
8929
  // Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
8487
8930
  // ----------------------------------------------------------------------------
@@ -8911,3 +9354,78 @@ export function setBudgetRefusalFollowUpTaskId(
8911
9354
  )
8912
9355
  .run(followUpTaskId, taskId, date);
8913
9356
  }
9357
+
9358
+ // ============================================================================
9359
+ // /status helpers — instance activity + first-task milestone
9360
+ // ============================================================================
9361
+
9362
+ /**
9363
+ * Count agents that have heartbeated within the last `minutes` minutes,
9364
+ * grouped by lead/worker. Used by the `workers` setup milestone on
9365
+ * `GET /status` to flip from `configured` → `verified` only when both a lead
9366
+ * and at least one worker are alive.
9367
+ *
9368
+ * "Recent" defaults to 5 minutes — a multiple of `ACTIVITY_THROTTLE_MS = 5_000`
9369
+ * (`src/providers/swarm-events-shared.ts:48-49`) plus margin for missed
9370
+ * heartbeats. Agents with `status = 'offline'` are excluded.
9371
+ */
9372
+ export function getLiveAgentCounts(minutes: number = 5): {
9373
+ leads_alive: number;
9374
+ workers_alive: number;
9375
+ } {
9376
+ const row = getDb()
9377
+ .prepare<{ leads_alive: number | null; workers_alive: number | null }, [number]>(
9378
+ `SELECT
9379
+ SUM(CASE WHEN isLead = 1 THEN 1 ELSE 0 END) AS leads_alive,
9380
+ SUM(CASE WHEN isLead = 0 THEN 1 ELSE 0 END) AS workers_alive
9381
+ FROM agents
9382
+ WHERE lastActivityAt IS NOT NULL
9383
+ AND lastActivityAt >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ?1 || ' minutes')
9384
+ AND status != 'offline'`,
9385
+ )
9386
+ .get(minutes);
9387
+ return {
9388
+ leads_alive: row?.leads_alive ?? 0,
9389
+ workers_alive: row?.workers_alive ?? 0,
9390
+ };
9391
+ }
9392
+
9393
+ /**
9394
+ * Aggregate activity numbers for `GET /status`'s `activity` block.
9395
+ * - `agents_online` / `leads_online`: heartbeated within the last 5 minutes.
9396
+ * - `recent_tasks_count`: agent_tasks rows created in the last 24 hours.
9397
+ *
9398
+ * `agents_online` reports total alive agents (leads + workers) so the home
9399
+ * page can show a single "online" stat without summing on the client.
9400
+ */
9401
+ export function getInstanceActivity(): {
9402
+ agents_online: number;
9403
+ leads_online: number;
9404
+ recent_tasks_count: number;
9405
+ } {
9406
+ const { leads_alive, workers_alive } = getLiveAgentCounts(5);
9407
+ const tasksRow = getDb()
9408
+ .prepare<{ count: number }, []>(
9409
+ `SELECT COUNT(*) AS count FROM agent_tasks
9410
+ WHERE createdAt >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')`,
9411
+ )
9412
+ .get();
9413
+ return {
9414
+ agents_online: leads_alive + workers_alive,
9415
+ leads_online: leads_alive,
9416
+ recent_tasks_count: tasksRow?.count ?? 0,
9417
+ };
9418
+ }
9419
+
9420
+ /**
9421
+ * `first_task` milestone: true once any task has reached `status = 'completed'`.
9422
+ * Cheap LIMIT 1 probe; the row's contents don't matter, only existence.
9423
+ */
9424
+ export function hasFirstCompletedTask(): boolean {
9425
+ const row = getDb()
9426
+ .prepare<{ one: number }, []>(
9427
+ `SELECT 1 AS one FROM agent_tasks WHERE status = 'completed' LIMIT 1`,
9428
+ )
9429
+ .get();
9430
+ return row !== null;
9431
+ }