@desplega.ai/agent-swarm 2.5.1 → 2.5.3

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 (197) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
  14. package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
  15. package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  21. package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
  22. package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
  23. package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
  24. package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
  25. package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
  26. package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
  27. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  28. package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
  29. package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
  30. package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
  31. package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
  32. package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
  33. package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
  34. package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
  35. package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
  36. package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
  37. package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
  38. package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
  39. package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
  40. package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
  41. package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
  42. package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
  43. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  44. package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
  45. package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
  46. package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
  47. package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
  48. package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
  49. package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
  50. package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
  51. package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
  52. package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
  53. package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
  54. package/dist/cli.js +19 -18
  55. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
  56. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
  57. package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
  58. package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
  59. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
  60. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
  61. package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
  62. package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
  63. package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
  64. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
  65. package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
  66. package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
  67. package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
  68. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
  69. package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
  70. package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
  71. package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
  72. package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
  73. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  74. package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
  75. package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
  76. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  77. package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
  78. package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
  79. package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
  80. package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
  81. package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
  82. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  83. package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
  84. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  85. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
  86. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
  87. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
  88. package/dist/rbac-roles-gfn30fb8.js +26 -0
  89. package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
  90. package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
  91. package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
  92. package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
  93. package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
  94. package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
  95. package/openapi.json +3163 -2114
  96. package/package.json +7 -6
  97. package/src/be/approval-config-question.ts +389 -0
  98. package/src/be/automation-preflight.ts +37 -3
  99. package/src/be/db/session-tree.ts +158 -13
  100. package/src/be/db/session-visibility.ts +31 -0
  101. package/src/be/db/skill-search.ts +87 -0
  102. package/src/be/db/tasks/read.ts +47 -7
  103. package/src/be/db/workflow-runs.ts +54 -7
  104. package/src/be/db.ts +27 -5
  105. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  106. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  107. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  108. package/src/be/migrations/239_skills_fts.sql +40 -0
  109. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  110. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  111. package/src/be/rbac-roles.ts +138 -0
  112. package/src/be/scripts/run-credential.ts +64 -0
  113. package/src/be/scripts/run-saved.ts +3 -2
  114. package/src/be/swarm-config-guard.ts +23 -0
  115. package/src/be/users.ts +28 -4
  116. package/src/extensions/catalog.generated.json +4 -4
  117. package/src/http/all-routes.ts +1 -0
  118. package/src/http/approval-requests.ts +254 -43
  119. package/src/http/config.ts +22 -37
  120. package/src/http/index.ts +2 -0
  121. package/src/http/schedules.ts +26 -0
  122. package/src/http/scripts.ts +2 -2
  123. package/src/http/sessions.ts +118 -2
  124. package/src/http/user-roles.ts +173 -0
  125. package/src/http/users.ts +9 -0
  126. package/src/http/x.ts +2 -2
  127. package/src/integrations/chat-sdk/commands.ts +14 -1
  128. package/src/integrations/chat-sdk/inbound.ts +42 -6
  129. package/src/integrations/chat-sdk/outbound.ts +35 -10
  130. package/src/integrations/chat-sdk/reactions.ts +107 -0
  131. package/src/integrations/chat-sdk/relay.ts +75 -7
  132. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  133. package/src/integrations/reaction-events.ts +20 -0
  134. package/src/providers/amp-adapter.ts +1 -1
  135. package/src/providers/dsh-adapter.ts +1 -1
  136. package/src/rbac/legacy-policy.ts +3 -0
  137. package/src/rbac/permissions.ts +8 -0
  138. package/src/scheduler/schedule-task.ts +44 -3
  139. package/src/scheduler/scheduler.ts +4 -4
  140. package/src/scripts-runtime/loader.ts +8 -1
  141. package/src/slack/ack.ts +2 -1
  142. package/src/slack/reaction-shortcode.ts +4 -7
  143. package/src/tasks/worker-follow-up.ts +30 -0
  144. package/src/tests/approval-config-question.test.ts +702 -0
  145. package/src/tests/approval-requests-notifications.test.ts +189 -0
  146. package/src/tests/chat-sdk-commands.test.ts +12 -0
  147. package/src/tests/chat-sdk-inbound.test.ts +183 -9
  148. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  149. package/src/tests/chat-sdk-relay.test.ts +361 -10
  150. package/src/tests/chat-sdk-tools.test.ts +104 -0
  151. package/src/tests/docs-index-footprint.test.js +3 -3
  152. package/src/tests/dsh-adapter.test.ts +1 -1
  153. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  154. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  155. package/src/tests/http-users.test.ts +38 -0
  156. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  157. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  158. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  159. package/src/tests/rbac-engine.test.ts +2 -0
  160. package/src/tests/rbac-roles.test.ts +10 -0
  161. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  162. package/src/tests/script-api-credential.test.ts +173 -0
  163. package/src/tests/script-connections.test.ts +10 -0
  164. package/src/tests/script-run-config-scope.test.ts +120 -0
  165. package/src/tests/script-run-token.test.ts +75 -0
  166. package/src/tests/scripts-http.test.ts +19 -0
  167. package/src/tests/sessions-tree.test.ts +410 -1
  168. package/src/tests/skill-search-long-content.test.ts +193 -0
  169. package/src/tests/skill-search.test.ts +173 -22
  170. package/src/tests/user-roles-http.test.ts +204 -0
  171. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  172. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  173. package/src/tools/chat-reply.ts +2 -0
  174. package/src/tools/request-human-input.ts +27 -5
  175. package/src/tools/schedules/patch-schedule.ts +18 -0
  176. package/src/tools/schedules/update-schedule.ts +18 -0
  177. package/src/types.ts +23 -0
  178. package/src/workflows/approval-notifications.ts +221 -0
  179. package/src/workflows/checkpoint.ts +69 -35
  180. package/src/workflows/engine.ts +234 -87
  181. package/src/workflows/executors/base.ts +6 -0
  182. package/src/workflows/executors/human-in-the-loop.ts +36 -173
  183. package/src/workflows/executors/sub-workflow.ts +45 -16
  184. package/src/workflows/executors/swarm-script.ts +2 -2
  185. package/src/workflows/recovery.ts +63 -1
  186. package/src/workflows/resume.ts +60 -21
  187. package/src/workflows/retry-poller.ts +25 -4
  188. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  189. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  190. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  191. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  192. package/templates/skills/code-quality/content.md +1 -1
  193. package/templates/skills/turso-interaction/content.md +1 -1
  194. package/templates/skills/workflow-iterate/content.md +8 -1
  195. package/dist/rbac-roles-ff1ffmec.js +0 -12
  196. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  197. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -6,12 +6,23 @@ import {
6
6
  } from "../../assets/key";
7
7
  import { isTerminalTaskStatus, TERMINAL_TASK_STATUSES } from "../../types";
8
8
  import { getDbClient, literalPrefixPattern } from "../db";
9
- import { type SessionViewer, sessionViewerCondition } from "./session-visibility";
9
+ import {
10
+ type SessionViewer,
11
+ sessionMineCondition,
12
+ sessionViewerCondition,
13
+ } from "./session-visibility";
10
14
 
11
15
  export type SessionReadState = "unread" | "read" | "later" | "done";
12
16
  export interface SessionFilters {
13
17
  /** Restrict roots to these task sources. */
14
18
  source?: string[];
19
+ /** Leave out roots of these task sources (the older sessions v2 source filter). */
20
+ hideSource?: string[];
21
+ /**
22
+ * Keep only roots of these task sources (the sessions v2 source filter).
23
+ * Unlike `source`, an empty list matches no root.
24
+ */
25
+ showSource?: string[];
15
26
  /** Match root task text or title with a case-insensitive substring. */
16
27
  q?: string;
17
28
  /** Match the root requester, excluding NULL requester rows. */
@@ -36,19 +47,25 @@ export interface SessionFilters {
36
47
  now?: string;
37
48
  /** Exclude ownerless roots for user viewers to match the tree's visibility. */
38
49
  tree?: boolean;
50
+ /** For user viewers: only human-started roots of the viewer and shares (the "Mine" view). */
51
+ mine?: boolean;
39
52
  /** Match exactly this root (one session's summary, e.g. its read state). */
40
53
  rootId?: string;
41
54
  }
42
55
 
43
56
  type SqlFragment = { sql: string; params: (string | number | null)[] };
44
57
 
45
- /** Shared read-state rule with bound ISO timestamps for the list and tree. */
58
+ /**
59
+ * Shared read-state rule with bound ISO timestamps for the list and tree.
60
+ * Done holds through agent activity (progress, status changes, child tasks):
61
+ * only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
62
+ */
46
63
  function sessionReadStateSql(now = new Date().toISOString()): SqlFragment {
47
64
  return {
48
65
  sql: `CASE
49
66
  WHEN s.id IS NULL THEN CASE WHEN a.lastActivityAt > ? THEN 'unread' ELSE 'read' END
50
67
  WHEN s.status = 'dismissed' THEN 'done'
51
- WHEN s.status = 'done' THEN CASE WHEN a.lastActivityAt > s.lastUpdatedAt THEN 'unread' ELSE 'done' END
68
+ WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
52
69
  WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
53
70
  WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
54
71
  ELSE 'read'
@@ -89,6 +106,17 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
89
106
  const sources = opts.source?.filter(Boolean) ?? [];
90
107
  if (sources.length)
91
108
  add({ sql: `r.source IN (${sources.map(() => "?").join(",")})`, params: sources });
109
+ const hidden = opts.hideSource?.filter(Boolean) ?? [];
110
+ if (hidden.length)
111
+ add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
112
+ if (opts.showSource) {
113
+ const shown = opts.showSource.filter(Boolean);
114
+ add(
115
+ shown.length
116
+ ? { sql: `r.source IN (${shown.map(() => "?").join(",")})`, params: shown }
117
+ : { sql: "0", params: [] },
118
+ );
119
+ }
92
120
  if (opts.q?.trim()) {
93
121
  const like = `%${opts.q.trim().toLowerCase()}%`;
94
122
  add({
@@ -106,6 +134,7 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
106
134
  if (opts.viewer) {
107
135
  add(sessionViewerCondition(opts.viewer));
108
136
  if (opts.tree) conditions.push("r.requestedByUserId IS NOT NULL");
137
+ if (opts.mine) add(sessionMineCondition(opts.viewer.userId));
109
138
  }
110
139
  if (opts.keyPrefix) {
111
140
  const { lo, hi } = keyPrefixRange(opts.keyPrefix);
@@ -140,6 +169,35 @@ function hasSessionChainFilter(opts: SessionFilters): boolean {
140
169
 
141
170
  const runningSql = `latestStatus NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(",")})`;
142
171
 
172
+ /**
173
+ * Sources of tasks that agents and the server create: delegation and scripts
174
+ * (`mcp`), follow-ups (`system`), deferred wake-ups (`schedule`) and workflow
175
+ * steps. Agents also set `requestedByUserId` explicitly on these, so a requester
176
+ * alone does not mark a human message.
177
+ */
178
+ const AGENT_TASK_SOURCES = ["mcp", "system", "schedule", "workflow"] as const;
179
+
180
+ /**
181
+ * Per chain row, over `chain` and the viewer's Done row `d`: whether the row
182
+ * needs the user since they marked the session Done. That is a human message
183
+ * (a task attributed to a person directly, not copied from its parent, from a
184
+ * human channel), a person's steering message on a chain task (steer or
185
+ * queue, not cancelled), or a pending approval, created after the Done mark.
186
+ * Steering `created_at` and the Done mark's `lastUpdatedAt` are both written
187
+ * as `%Y-%m-%dT%H:%M:%fZ`, so they compare as text.
188
+ */
189
+ const reopensDoneSql = `(d.id IS NOT NULL AND (
190
+ (chain.createdAt > d.lastUpdatedAt
191
+ AND chain.requestedByUserId IS NOT NULL
192
+ AND chain.requestedByUserIdInherited = 0
193
+ AND COALESCE(chain.source, '') NOT IN (${AGENT_TASK_SOURCES.map((s) => `'${s}'`).join(",")}))
194
+ OR EXISTS (SELECT 1 FROM task_steering_messages sm
195
+ WHERE sm.task_id = chain.id AND sm.created_by_kind = 'user'
196
+ AND sm.status != 'cancelled' AND sm.created_at > d.lastUpdatedAt)
197
+ OR EXISTS (SELECT 1 FROM approval_requests ar
198
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
199
+ ))`;
200
+
143
201
  /**
144
202
  * A session starts at a parentTaskId IS NULL root. One recursive CTE computes
145
203
  * chain-wide latest activity for list ordering. Root filters precede recursion.
@@ -150,7 +208,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
150
208
  const read = opts.viewer?.userId ? sessionReadStateSql(opts.now) : { sql: "NULL", params: [] };
151
209
  const params = [...root.params];
152
210
  if (opts.withUserId) params.push(opts.withUserId);
153
- params.push(...read.params, opts.viewer?.userId ?? null);
211
+ params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
154
212
  const filters: string[] = [];
155
213
  if (opts.triage === "unread") filters.push("readState = 'unread'");
156
214
  else if (opts.triage === "later" || opts.triage === "done") {
@@ -170,19 +228,27 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
170
228
  return {
171
229
  // SQLite takes the bare status from the row with MAX(lastUpdatedAt).
172
230
  // Keep this as the only MIN/MAX aggregate so status follows activity.
173
- sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId) AS (
174
- SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId
231
+ // `d` is the viewer's Done row, so `reopensDone` can compare chain rows
232
+ // against the Done mark (SUM keeps MAX the only MIN/MAX aggregate).
233
+ sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId,
234
+ createdAt, source, requestedByUserIdInherited) AS (
235
+ SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
236
+ r.createdAt, r.source, r.requestedByUserIdInherited
175
237
  FROM agent_tasks r WHERE ${root.sql}
176
238
  UNION ALL
177
- SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId
239
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
240
+ t.createdAt, t.source, t.requestedByUserIdInherited
178
241
  FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
179
242
  ), agg AS (
180
- SELECT root_id, COUNT(*) AS chainCount,
181
- status AS latestStatus, MAX(lastUpdatedAt) AS lastActivityAt,
182
- ${opts.withUserId ? "SUM(requestedByUserId = ?) AS withUser," : ""}
243
+ SELECT chain.root_id, COUNT(*) AS chainCount,
244
+ chain.status AS latestStatus, MAX(chain.lastUpdatedAt) AS lastActivityAt,
245
+ ${opts.withUserId ? "SUM(chain.requestedByUserId = ?) AS withUser," : ""}
183
246
  SUM(EXISTS (SELECT 1 FROM approval_requests ar
184
- WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
185
- FROM chain GROUP BY root_id
247
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval,
248
+ SUM(${reopensDoneSql}) AS reopensDone
249
+ FROM chain LEFT JOIN inbox_item_state d
250
+ ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = chain.root_id AND d.status = 'done'
251
+ GROUP BY chain.root_id
186
252
  ), state AS (
187
253
  SELECT a.*, ${read.sql} AS readState, s.snoozeUntil
188
254
  FROM agg a LEFT JOIN inbox_item_state s
@@ -322,25 +388,32 @@ export interface SessionTree {
322
388
  later: SessionSectionCounts;
323
389
  done: SessionSectionCounts;
324
390
  noGroup: SessionSectionCounts;
391
+ /** Each root source in this view with its session count, hidden sources included, most first. */
392
+ sources: { source: string; total: number }[];
325
393
  }
326
394
 
327
395
  export async function getSessionTree(
328
396
  actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
329
397
  now?: string,
398
+ opts: { mine?: boolean; hideSource?: string[]; showSource?: string[] } = {},
330
399
  ): Promise<SessionTree> {
400
+ // No source filter in SQL: the source counts must include hidden sources,
401
+ // so the filter menu can offer them back. The loop skips them instead.
331
402
  const query = sessionSummaryQuery({
332
403
  ...(actor.kind === "user" ? { viewer: { userId: actor.userId, scope: "all" } as const } : {}),
333
404
  now,
334
405
  tree: true,
406
+ mine: opts.mine,
335
407
  });
336
408
  const rows = await getDbClient().query<{
337
409
  key: string;
410
+ source: string;
338
411
  lastActivityAt: string;
339
412
  latestStatus: string;
340
413
  readState: SessionReadState | null;
341
414
  needsYou: number;
342
415
  }>(
343
- `SELECT r."key", a.lastActivityAt, a.latestStatus, a.readState, a.needsYou
416
+ `SELECT r."key", r.source, a.lastActivityAt, a.latestStatus, a.readState, a.needsYou
344
417
  FROM (${query.sql}) a CROSS JOIN agent_tasks r ON r.id = a.root_id`,
345
418
  query.params,
346
419
  );
@@ -351,7 +424,11 @@ export async function getSessionTree(
351
424
  later: counts(),
352
425
  done: counts(),
353
426
  noGroup: counts(),
427
+ sources: [],
354
428
  };
429
+ const hidden = new Set(opts.hideSource);
430
+ const shown = opts.showSource ? new Set(opts.showSource) : null;
431
+ const sources = new Map<string, number>();
355
432
  const groups = new Map<string, SessionTreeGroup>();
356
433
  const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
357
434
  section.total++;
@@ -360,6 +437,8 @@ export async function getSessionTree(
360
437
  if (row.needsYou) section.needsYou++;
361
438
  };
362
439
  for (const row of rows) {
440
+ sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
441
+ if (hidden.has(row.source) || (shown && !shown.has(row.source))) continue;
363
442
  const key = sessionGroupKey(row.key);
364
443
  const noGroup =
365
444
  key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
@@ -380,5 +459,71 @@ export async function getSessionTree(
380
459
  }
381
460
  }
382
461
  result.groups = [...groups.values()].sort((a, b) => a.key.localeCompare(b.key));
462
+ result.sources = [...sources]
463
+ .map(([source, total]) => ({ source, total }))
464
+ .sort((a, b) => b.total - a.total || a.source.localeCompare(b.source));
383
465
  return result;
384
466
  }
467
+
468
+ /** Triage actions on a whole list: the bulk bar's "Select all N". */
469
+ export type SessionListTriage = "done" | "inbox" | "read" | "unread";
470
+
471
+ /** The read cursor of "unread". The dashboard and the my-swarm app write the same value. */
472
+ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
473
+
474
+ /**
475
+ * Apply one triage action to every session a sidebar list shows `userId`
476
+ * (the same filters as `GET /api/sessions?scope=tree`), in one statement.
477
+ * The rows match what the dashboard writes for one session: Done, back to
478
+ * the Inbox, read up to the newest activity, or unread. Returns the number
479
+ * of sessions written.
480
+ */
481
+ export async function triageSessionList(
482
+ userId: string,
483
+ filters: Pick<
484
+ SessionFilters,
485
+ "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "showSource" | "now"
486
+ >,
487
+ action: SessionListTriage,
488
+ ): Promise<number> {
489
+ const now = filters.now ?? new Date().toISOString();
490
+ const query = sessionSummaryQuery({
491
+ ...filters,
492
+ now,
493
+ viewer: { userId, scope: "all" },
494
+ tree: true,
495
+ });
496
+ const status = action === "done" ? "done" : "open";
497
+ // `read` moves the cursor to each session's newest activity, `unread` to the epoch.
498
+ const lastReadAt =
499
+ action === "read" ? "a.lastActivityAt" : action === "unread" ? `'${UNREAD_CURSOR}'` : "NULL";
500
+ const setsLastRead = action === "read" || action === "unread";
501
+ // The conflict rules copy `upsertInboxState` in src/be/db.ts (sticky readAt,
502
+ // lastReadAt only when the action sets it, snooze and dismiss cleared). Keep
503
+ // the two in step. `WHERE true` is required: SQLite cannot parse
504
+ // INSERT ... SELECT ... ON CONFLICT when the SELECT has no WHERE clause.
505
+ const result = await getDbClient().run(
506
+ `INSERT INTO inbox_item_state
507
+ (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, readAt, lastReadAt, createdAt, lastUpdatedAt)
508
+ SELECT ?, 'to_read', a.root_id, ?, NULL, NULL, ?, ?, ${lastReadAt}, ?, ?
509
+ FROM (${query.sql}) a WHERE true
510
+ ON CONFLICT(userId, itemType, itemId) DO UPDATE SET
511
+ status = excluded.status,
512
+ snoozeUntil = NULL,
513
+ dismissedAt = NULL,
514
+ doneAt = excluded.doneAt,
515
+ readAt = COALESCE(inbox_item_state.readAt, excluded.readAt),
516
+ lastReadAt = ${setsLastRead ? "excluded.lastReadAt" : "inbox_item_state.lastReadAt"},
517
+ lastUpdatedAt = excluded.lastUpdatedAt`,
518
+ [
519
+ userId,
520
+ status,
521
+ action === "done" ? now : null,
522
+ action === "read" ? now : null,
523
+ now,
524
+ now,
525
+ ...query.params,
526
+ ],
527
+ );
528
+ return result.changes;
529
+ }
@@ -27,3 +27,34 @@ export function sessionViewerCondition(viewer: SessionViewer): {
27
27
  params: [viewer.userId, viewer.userId],
28
28
  };
29
29
  }
30
+
31
+ /**
32
+ * Root sources that a machine starts: a workflow run, a schedule, or the
33
+ * swarm itself (heartbeat, follow-ups, budget and approval notices). A
34
+ * workflow root carries the user whose trigger fired it, so the requester
35
+ * alone does not tell a person's session from fan-out. `kapso-inbound` is the
36
+ * exception: a WhatsApp message a person sent, written with `system`.
37
+ */
38
+ export const MACHINE_SESSION_SOURCES = ["workflow", "schedule", "system"] as const;
39
+ const HUMAN_SYSTEM_TASK_TYPES = ["kapso-inbound"] as const;
40
+
41
+ /**
42
+ * The sessions v2 "Mine" view (alias `r`): roots this user started from a
43
+ * human channel (every source outside {@link MACHINE_SESSION_SOURCES}), plus
44
+ * sessions shared with this user or everyone. A list filter only: it narrows
45
+ * what a list shows and never changes who can open a session.
46
+ */
47
+ export function sessionMineCondition(userId: string | null): {
48
+ sql: string;
49
+ params: (string | null)[];
50
+ } {
51
+ const machine = MACHINE_SESSION_SOURCES.map(() => "?").join(",");
52
+ const humanSystem = HUMAN_SYSTEM_TASK_TYPES.map(() => "?").join(",");
53
+ return {
54
+ sql: `((r.requestedByUserId = ? AND (r.source NOT IN (${machine})
55
+ OR (r.source = 'system' AND r.taskType IN (${humanSystem}))))
56
+ OR EXISTS (SELECT 1 FROM session_shares s
57
+ WHERE s.rootTaskId = r.id AND s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?)))`,
58
+ params: [userId, ...MACHINE_SESSION_SOURCES, ...HUMAN_SYSTEM_TASK_TYPES, userId],
59
+ };
60
+ }
@@ -0,0 +1,87 @@
1
+ import { getDbClient } from "./runtime";
2
+
3
+ // bm25() takes one weight per FTS column, in declaration order.
4
+ // skills_fts (migration 239): skill_id (UNINDEXED), name, description, content.
5
+ // skills_meta_fts (migration 241): skill_id (UNINDEXED), name, description.
6
+ const SKILLS_FTS_BM25 = "bm25(skills_fts, 0.0, 3.0, 2.0, 1.0)";
7
+ const SKILLS_META_FTS_BM25 = "bm25(skills_meta_fts, 0.0, 3.0, 2.0)";
8
+
9
+ // FTS5 bm25 normalizes by the length of the whole row, so in skills_fts a
10
+ // long SKILL.md body buries a name or description hit. skills_meta_fts holds
11
+ // only those short fields; its score is added at this multiple. On the live
12
+ // catalog, multiples from 2 to 10 rank the same; 3 is mid-range.
13
+ const META_SCORE_MULTIPLE = 3;
14
+
15
+ // English function words that agents put in natural-language queries. Each
16
+ // one matches most of the catalog ("a" matched 189 of 189 skills) and only
17
+ // adds noise to the ranking.
18
+ const STOPWORDS = new Set(
19
+ (
20
+ "a an and are as at be but by can do does for from get got have how i if in into is it its " +
21
+ "me my need of on or our should so that the their them then there these this to use using " +
22
+ "want was we what when where which who why will with would you your"
23
+ ).split(" "),
24
+ );
25
+
26
+ /**
27
+ * Build a skills_fts MATCH expression from free text. Every term is quoted as
28
+ * an FTS5 string, so user text cannot inject operators (NEAR, AND, -, *, ^,
29
+ * column filters). Stopwords are dropped and the remaining terms are OR-ed:
30
+ * ranked recall, ordered by bm25. Returns null when no term is left, so the
31
+ * caller falls back to the substring match.
32
+ */
33
+ export function buildSkillsFtsMatch(query: string): string | null {
34
+ const terms = query
35
+ .split(/[^\p{L}\p{N}_-]+/u)
36
+ .filter((term) => /[\p{L}\p{N}]/u.test(term) && !STOPWORDS.has(term.toLowerCase()))
37
+ .slice(0, 12);
38
+ if (terms.length === 0) return null;
39
+ return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
40
+ }
41
+
42
+ /**
43
+ * Enabled skills ranked by BM25: the skills_fts score (name 3, description 2,
44
+ * content 1) plus META_SCORE_MULTIPLE times the skills_meta_fts score (name 3,
45
+ * description 2). A skill whose name equals the whole query (case-insensitive)
46
+ * ranks first. Returns [] when the query has no indexable terms or the
47
+ * backend cannot run the FTS query, so the caller can fall back.
48
+ *
49
+ * `columns` is a select list over `skills`; it must not name a bare
50
+ * `skill_id` or `score`, which the joined `hits` and `meta` CTEs also expose.
51
+ */
52
+ export async function searchSkillRowsRanked<T>(
53
+ query: string,
54
+ limit: number,
55
+ columns: string,
56
+ ): Promise<T[]> {
57
+ const match = buildSkillsFtsMatch(query);
58
+ if (!match) return [];
59
+ const exactName = query.trim();
60
+ try {
61
+ return await getDbClient().query<T>(
62
+ `WITH hits AS (
63
+ SELECT skill_id, ${SKILLS_FTS_BM25} AS score FROM skills_fts WHERE skills_fts MATCH ?
64
+ ),
65
+ meta AS (
66
+ SELECT skill_id, ${SKILLS_META_FTS_BM25} AS score
67
+ FROM skills_meta_fts WHERE skills_meta_fts MATCH ?
68
+ )
69
+ SELECT ${columns} FROM skills
70
+ LEFT JOIN hits ON hits.skill_id = skills.id
71
+ LEFT JOIN meta ON meta.skill_id = skills.id
72
+ WHERE skills.isEnabled = 1
73
+ AND (hits.skill_id IS NOT NULL OR lower(skills.name) = lower(?))
74
+ ORDER BY lower(skills.name) = lower(?) DESC,
75
+ COALESCE(hits.score, 0) + ${META_SCORE_MULTIPLE} * COALESCE(meta.score, 0) ASC,
76
+ skills.name ASC
77
+ LIMIT ?`,
78
+ [match, match, exactName, exactName, limit],
79
+ );
80
+ } catch (err) {
81
+ console.warn(
82
+ "[skills-fts] ranked query failed, using substring match:",
83
+ (err as Error).message,
84
+ );
85
+ return [];
86
+ }
87
+ }
@@ -196,12 +196,39 @@ type AgentTaskTimelineRow = Pick<
196
196
  * past it).
197
197
  */
198
198
  const TASK_PREVIEW_BYTES = 4 * (TASK_PREVIEW_LENGTH + 1);
199
- const TASK_PREVIEW_SQL = `substr(CAST(agent_tasks.task AS BLOB), 1, ${TASK_PREVIEW_BYTES}) AS task`;
199
+ /**
200
+ * A leading `<sibling_tasks_in_progress>` block (`src/tasks/sibling-block.ts`)
201
+ * is generated context that can fill the whole preview, so the list row never
202
+ * reaches the ask. The preview starts after the block and the `\n\n`
203
+ * `prependSiblingBlock` puts after it. A block with no closing tag stays.
204
+ */
205
+ const SIBLING_OPEN = "<sibling_tasks_in_progress>";
206
+ const SIBLING_CLOSE = "</sibling_tasks_in_progress>";
207
+ const TASK_BLOB = "CAST(agent_tasks.task AS BLOB)";
208
+ const SIBLING_CLOSE_AT = `instr(${TASK_BLOB}, CAST('${SIBLING_CLOSE}' AS BLOB))`;
209
+ const SIBLING_END = `(${SIBLING_CLOSE_AT} + ${SIBLING_CLOSE.length})`;
210
+ const TASK_PREVIEW_START = `CASE
211
+ WHEN substr(${TASK_BLOB}, 1, ${SIBLING_OPEN.length}) = CAST('${SIBLING_OPEN}' AS BLOB)
212
+ AND ${SIBLING_CLOSE_AT} > 0
213
+ THEN ${SIBLING_END} + (substr(${TASK_BLOB}, ${SIBLING_END}, 2) = x'0a0a') * 2
214
+ ELSE 1 END`;
215
+ const TASK_PREVIEW_SQL = `substr(${TASK_BLOB}, ${TASK_PREVIEW_START}, ${TASK_PREVIEW_BYTES}) AS task`;
200
216
  // ignoreBOM keeps a leading U+FEFF, which the full-text preview keeps too.
201
217
  const taskPreviewDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
202
218
 
219
+ /** What `TASK_PREVIEW_START` does in SQL, for a full-text row. */
220
+ function skipSiblingBlock(text: string): string {
221
+ if (!text.startsWith(SIBLING_OPEN)) return text;
222
+ const close = text.indexOf(SIBLING_CLOSE);
223
+ if (close === -1) return text;
224
+ const end = close + SIBLING_CLOSE.length;
225
+ return text.slice(text.startsWith("\n\n", end) ? end + 2 : end);
226
+ }
227
+
203
228
  function taskPreview(task: TaskPreviewSource["task"]): string {
204
- const text = task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : task;
229
+ // Bytes come from `TASK_PREVIEW_SQL`, which already skipped the block.
230
+ const text =
231
+ task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : skipSiblingBlock(task ?? "");
205
232
  return dependencies.previewText(text, TASK_PREVIEW_LENGTH);
206
233
  }
207
234
 
@@ -1715,6 +1742,18 @@ export async function getLatestLeadTaskInThread(
1715
1742
  return row ? rowToAgentTask(row) : null;
1716
1743
  }
1717
1744
 
1745
+ /**
1746
+ * `source` filter of the contextKey lookups below. `'chat'` also matches a
1747
+ * defer-task wake-up of a chat task: it keeps the chat contextKey with
1748
+ * `source = 'schedule'` (src/scheduler/schedule-task.ts) and continues the
1749
+ * same ask, so `/cancel`, steering and routing treat it as the chat task.
1750
+ * Binds `source` three times.
1751
+ */
1752
+ function contextKeySourceSql(alias = ""): string {
1753
+ return `(? IS NULL OR ${alias}source = ?
1754
+ OR (? = 'chat' AND ${alias}source = 'schedule' AND ${alias}taskType = 'deferred'))`;
1755
+ }
1756
+
1718
1757
  /**
1719
1758
  * ContextKey twins of the Slack thread lookups above, for channels that key a
1720
1759
  * conversation by `contextKey` only (Chat SDK). `source` narrows to the
@@ -1731,7 +1770,7 @@ export async function getAgentWorkingOnContextKey(
1731
1770
  const taskRow = await getDbClient().get<AgentTaskRow>(
1732
1771
  `SELECT agentId FROM agent_tasks
1733
1772
  WHERE contextKey = ?
1734
- AND (? IS NULL OR source = ?)
1773
+ AND ${contextKeySourceSql()}
1735
1774
  AND (? IS NULL
1736
1775
  OR status NOT IN (${TERMINAL_TASK_STATUSES.map(() => "?").join(", ")})
1737
1776
  OR finishedAt >= ?)
@@ -1741,6 +1780,7 @@ export async function getAgentWorkingOnContextKey(
1741
1780
  contextKey,
1742
1781
  source ?? null,
1743
1782
  source ?? null,
1783
+ source ?? null,
1744
1784
  endedSince ?? null,
1745
1785
  ...TERMINAL_TASK_STATUSES,
1746
1786
  endedSince ?? null,
@@ -1760,11 +1800,11 @@ export async function getLatestActiveTaskByContextKey(
1760
1800
  const row = await getDbClient().get<AgentTaskRow>(
1761
1801
  `SELECT * FROM agent_tasks
1762
1802
  WHERE contextKey = ?
1763
- AND (? IS NULL OR source = ?)
1803
+ AND ${contextKeySourceSql()}
1764
1804
  AND status IN ('in_progress', 'pending')
1765
1805
  ORDER BY createdAt DESC, rowid DESC
1766
1806
  LIMIT 1`,
1767
- [contextKey, source ?? null, source ?? null],
1807
+ [contextKey, source ?? null, source ?? null, source ?? null],
1768
1808
  );
1769
1809
 
1770
1810
  return row ? rowToAgentTask(row) : null;
@@ -1780,11 +1820,11 @@ export async function getLatestLeadTaskByContextKey(
1780
1820
  FROM agent_tasks t
1781
1821
  JOIN agents a ON a.id = t.agentId
1782
1822
  WHERE t.contextKey = ?
1783
- AND (? IS NULL OR t.source = ?)
1823
+ AND ${contextKeySourceSql("t.")}
1784
1824
  AND a.isLead = 1
1785
1825
  ORDER BY t.createdAt DESC, t.rowid DESC
1786
1826
  LIMIT 1`,
1787
- [contextKey, source ?? null, source ?? null],
1827
+ [contextKey, source ?? null, source ?? null, source ?? null],
1788
1828
  );
1789
1829
 
1790
1830
  return row ? rowToAgentTask(row) : null;
@@ -4,7 +4,8 @@ import { scrubJsonValue } from "../scrub-json";
4
4
  import { getDbClient } from "./runtime";
5
5
 
6
6
  // Parent/child links between workflow runs (migration 203): a `sub-workflow`
7
- // step starts a child run that records the step in `parentStepId`.
7
+ // step starts a child run that records the step in `parentStepId`, and the
8
+ // step's `retryCount` at that time in `parentStepAttempt` (migration 240).
8
9
 
9
10
  const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
10
11
  "completed",
@@ -13,15 +14,60 @@ const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
13
14
  "skipped",
14
15
  ]);
15
16
 
16
- /** The id of the child run a `sub-workflow` step started, if any. */
17
- export async function getChildWorkflowRunId(parentStepId: string): Promise<string | null> {
17
+ /** The id of the child run a `sub-workflow` step started on `attempt`, if any. */
18
+ export async function getChildWorkflowRunId(
19
+ parentStepId: string,
20
+ attempt: number,
21
+ ): Promise<string | null> {
18
22
  const row = await getDbClient().get<{ id: string }>(
19
- "SELECT id FROM workflow_runs WHERE parentStepId = ?",
20
- [parentStepId],
23
+ "SELECT id FROM workflow_runs WHERE parentStepId = ? AND parentStepAttempt = ?",
24
+ [parentStepId, attempt],
21
25
  );
22
26
  return row?.id ?? null;
23
27
  }
24
28
 
29
+ /**
30
+ * True while `attempt` of the step is executing in a live run: the step is
31
+ * `running` with that `retryCount`. A child run is created only then, so a
32
+ * cancelled run, a step another attempt moved on, or a step already parked
33
+ * never gets a new child.
34
+ */
35
+ export async function isParentStepAttemptCurrent(
36
+ parentStepId: string,
37
+ attempt: number,
38
+ ): Promise<boolean> {
39
+ const row = await getDbClient().get<{ found: number }>(
40
+ `SELECT 1 AS found
41
+ FROM workflow_run_steps s
42
+ JOIN workflow_runs r ON r.id = s.runId
43
+ WHERE s.id = ? AND s.status = 'running' AND s.retryCount = ?
44
+ AND r.status IN ('running', 'waiting')`,
45
+ [parentStepId, attempt],
46
+ );
47
+ return row != null;
48
+ }
49
+
50
+ /**
51
+ * `sub-workflow` steps left `running` in a live run: each is its node's latest
52
+ * row, so a row a newer one superseded is not listed. The caller skips the
53
+ * ones this process is still executing.
54
+ */
55
+ export async function getRunningSubWorkflowSteps(): Promise<
56
+ Array<{ stepId: string; runId: string; nodeId: string }>
57
+ > {
58
+ return getDbClient().query<{ stepId: string; runId: string; nodeId: string }>(
59
+ `SELECT s.id AS stepId, s.runId AS runId, s.nodeId AS nodeId
60
+ FROM workflow_run_steps s
61
+ JOIN workflow_runs r ON r.id = s.runId
62
+ WHERE s.status = 'running' AND s.nodeType = 'sub-workflow'
63
+ AND r.status IN ('running', 'waiting')
64
+ AND s.rowid = (
65
+ SELECT MAX(rowid) FROM workflow_run_steps
66
+ WHERE runId = s.runId AND nodeId = s.nodeId
67
+ )`,
68
+ );
69
+ }
70
+
25
71
  /** Where a child run came from: the `sub-workflow` step that started it and that step's run. */
26
72
  export type WorkflowRunParent = {
27
73
  runId: string;
@@ -65,12 +111,13 @@ export async function getWorkflowRunLineage(runId: string, maxDepth: number): Pr
65
111
  return rows.map((row) => row.workflowId);
66
112
  }
67
113
 
68
- /** Waiting `sub-workflow` steps whose child run is terminal or deleted. */
114
+ /** Waiting `sub-workflow` steps whose current attempt's child run is terminal or deleted. */
69
115
  export async function getSettledChildRunParentSteps(): Promise<string[]> {
70
116
  const rows = await getDbClient().query<{ id: string }>(
71
117
  `SELECT s.id
72
118
  FROM workflow_run_steps s
73
- LEFT JOIN workflow_runs c ON c.parentStepId = s.id
119
+ LEFT JOIN workflow_runs c
120
+ ON c.parentStepId = s.id AND c.parentStepAttempt = s.retryCount
74
121
  WHERE s.status = 'waiting' AND s.nodeType = 'sub-workflow'
75
122
  AND (c.id IS NULL OR c.status IN ('completed', 'failed', 'cancelled', 'skipped'))`,
76
123
  );