stagent 0.9.5 → 0.10.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 (277) hide show
  1. package/README.md +5 -42
  2. package/dist/cli.js +42 -18
  3. package/docs/.coverage-gaps.json +13 -55
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/provider-runtimes.md +4 -0
  6. package/docs/features/schedules.md +32 -4
  7. package/docs/features/settings.md +28 -5
  8. package/docs/features/tables.md +9 -2
  9. package/docs/features/workflows.md +10 -4
  10. package/docs/journeys/developer.md +15 -1
  11. package/docs/journeys/personal-use.md +21 -4
  12. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  13. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  14. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  15. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  16. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  17. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  18. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  19. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  20. package/package.json +3 -2
  21. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  22. package/src/app/analytics/page.tsx +1 -21
  23. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  24. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  25. package/src/app/api/instance/config/route.ts +41 -0
  26. package/src/app/api/instance/init/route.ts +34 -0
  27. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  28. package/src/app/api/instance/upgrade/route.ts +96 -0
  29. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  30. package/src/app/api/memory/route.ts +0 -11
  31. package/src/app/api/notifications/route.ts +4 -2
  32. package/src/app/api/projects/[id]/route.ts +5 -155
  33. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  34. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  35. package/src/app/api/schedules/[id]/route.ts +9 -1
  36. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  37. package/src/app/api/schedules/route.ts +3 -12
  38. package/src/app/api/settings/openai/login/route.ts +22 -0
  39. package/src/app/api/settings/openai/logout/route.ts +7 -0
  40. package/src/app/api/settings/openai/route.ts +21 -1
  41. package/src/app/api/settings/providers/route.ts +35 -8
  42. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  43. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  44. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  45. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  46. package/src/app/api/tasks/[id]/execute/route.ts +0 -21
  47. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  48. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  49. package/src/app/api/workspace/context/route.ts +2 -0
  50. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  51. package/src/app/chat/page.tsx +11 -0
  52. package/src/app/inbox/page.tsx +12 -5
  53. package/src/app/layout.tsx +42 -21
  54. package/src/app/page.tsx +0 -2
  55. package/src/app/settings/page.tsx +6 -9
  56. package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
  57. package/src/components/chat/chat-command-popover.tsx +2 -2
  58. package/src/components/chat/chat-input.tsx +2 -3
  59. package/src/components/chat/chat-session-provider.tsx +720 -0
  60. package/src/components/chat/chat-shell.tsx +92 -401
  61. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  62. package/src/components/instance/instance-section.tsx +382 -0
  63. package/src/components/instance/upgrade-badge.tsx +219 -0
  64. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  65. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  66. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  67. package/src/components/notifications/inbox-list.tsx +11 -2
  68. package/src/components/notifications/notification-item.tsx +56 -2
  69. package/src/components/notifications/pending-approval-host.tsx +56 -37
  70. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  71. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  72. package/src/components/schedules/schedule-form.tsx +31 -0
  73. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  74. package/src/components/settings/auth-method-selector.tsx +19 -4
  75. package/src/components/settings/auth-status-badge.tsx +28 -3
  76. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  77. package/src/components/settings/openai-runtime-section.tsx +7 -1
  78. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  79. package/src/components/shared/app-sidebar.tsx +4 -3
  80. package/src/components/shared/command-palette.tsx +4 -5
  81. package/src/components/shared/theme-toggle.tsx +5 -24
  82. package/src/components/shared/workspace-indicator.tsx +61 -2
  83. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  84. package/src/components/tables/table-create-sheet.tsx +4 -0
  85. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  86. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  87. package/src/components/tables/table-spreadsheet.tsx +29 -5
  88. package/src/components/tables/table-toolbar.tsx +10 -1
  89. package/src/components/tasks/kanban-board.tsx +1 -0
  90. package/src/components/tasks/kanban-column.tsx +53 -14
  91. package/src/components/tasks/task-bento-grid.tsx +19 -0
  92. package/src/components/tasks/task-card.tsx +26 -3
  93. package/src/components/tasks/task-chip-bar.tsx +24 -0
  94. package/src/components/tasks/task-result-renderer.tsx +1 -1
  95. package/src/components/workflows/delay-step-body.tsx +109 -0
  96. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  97. package/src/components/workflows/loop-status-view.tsx +1 -1
  98. package/src/components/workflows/shared/step-result.tsx +78 -0
  99. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  100. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  101. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  102. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  103. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  104. package/src/components/workflows/workflow-form-view.tsx +133 -16
  105. package/src/components/workflows/workflow-status-view.tsx +30 -740
  106. package/src/instrumentation-node.ts +94 -0
  107. package/src/instrumentation.ts +4 -48
  108. package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
  109. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  110. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  111. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  112. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  113. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  114. package/src/lib/agents/claude-agent.ts +155 -18
  115. package/src/lib/agents/execution-manager.ts +0 -35
  116. package/src/lib/agents/learned-context.ts +0 -12
  117. package/src/lib/agents/learning-session.ts +18 -5
  118. package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
  119. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
  120. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
  121. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  122. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  123. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  124. package/src/lib/agents/runtime/openai-codex.ts +29 -60
  125. package/src/lib/agents/runtime/types.ts +8 -0
  126. package/src/lib/book/chapter-mapping.ts +11 -0
  127. package/src/lib/book/content.ts +10 -0
  128. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  129. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  130. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  131. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  132. package/src/lib/chat/active-streams.ts +27 -0
  133. package/src/lib/chat/codex-engine.ts +16 -17
  134. package/src/lib/chat/context-builder.ts +5 -3
  135. package/src/lib/chat/engine.ts +50 -3
  136. package/src/lib/chat/reconcile.ts +117 -0
  137. package/src/lib/chat/stagent-tools.ts +1 -0
  138. package/src/lib/chat/stream-telemetry.ts +132 -0
  139. package/src/lib/chat/suggested-prompts.ts +28 -1
  140. package/src/lib/chat/system-prompt.ts +26 -1
  141. package/src/lib/chat/tool-catalog.ts +2 -1
  142. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  143. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  144. package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
  145. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
  146. package/src/lib/chat/tools/document-tools.ts +29 -13
  147. package/src/lib/chat/tools/helpers.ts +39 -0
  148. package/src/lib/chat/tools/notification-tools.ts +9 -5
  149. package/src/lib/chat/tools/project-tools.ts +33 -0
  150. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  151. package/src/lib/chat/tools/table-tools.ts +71 -0
  152. package/src/lib/chat/tools/task-tools.ts +84 -20
  153. package/src/lib/chat/tools/workflow-tools.ts +234 -32
  154. package/src/lib/constants/settings.ts +8 -18
  155. package/src/lib/data/__tests__/clear.test.ts +56 -2
  156. package/src/lib/data/clear.ts +20 -15
  157. package/src/lib/data/delete-project.ts +171 -0
  158. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  159. package/src/lib/db/bootstrap.ts +45 -16
  160. package/src/lib/db/index.ts +5 -0
  161. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  162. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  163. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  164. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  165. package/src/lib/db/migrations/meta/_journal.json +21 -0
  166. package/src/lib/db/schema.ts +68 -23
  167. package/src/lib/environment/workspace-context.ts +13 -1
  168. package/src/lib/import/dedup.ts +4 -54
  169. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  170. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  171. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  172. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  173. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  174. package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
  175. package/src/lib/instance/bootstrap.ts +270 -0
  176. package/src/lib/instance/detect.ts +49 -0
  177. package/src/lib/instance/fingerprint.ts +78 -0
  178. package/src/lib/instance/git-ops.ts +95 -0
  179. package/src/lib/instance/settings.ts +61 -0
  180. package/src/lib/instance/types.ts +77 -0
  181. package/src/lib/instance/upgrade-poller.ts +153 -0
  182. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  183. package/src/lib/notifications/visibility.ts +33 -0
  184. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  185. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  186. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  187. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  188. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  189. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  190. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  191. package/src/lib/schedules/collision-check.ts +105 -0
  192. package/src/lib/schedules/config.ts +53 -0
  193. package/src/lib/schedules/scheduler.ts +232 -13
  194. package/src/lib/schedules/slot-claim.ts +105 -0
  195. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  196. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  197. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  198. package/src/lib/settings/openai-auth.ts +105 -10
  199. package/src/lib/settings/openai-login-manager.ts +260 -0
  200. package/src/lib/settings/runtime-setup.ts +14 -4
  201. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  202. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  203. package/src/lib/tables/enrichment-planner.ts +454 -0
  204. package/src/lib/tables/enrichment.ts +328 -0
  205. package/src/lib/tables/query-builder.ts +5 -2
  206. package/src/lib/tables/trigger-evaluator.ts +3 -2
  207. package/src/lib/theme.ts +71 -0
  208. package/src/lib/usage/ledger.ts +2 -18
  209. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  210. package/src/lib/util/similarity.ts +77 -0
  211. package/src/lib/utils/format-timestamp.ts +24 -0
  212. package/src/lib/utils/stagent-paths.ts +12 -0
  213. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  214. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  215. package/src/lib/validators/blueprint.ts +70 -9
  216. package/src/lib/validators/profile.ts +2 -2
  217. package/src/lib/validators/settings.ts +3 -1
  218. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  219. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  220. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  221. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  222. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  223. package/src/lib/workflows/blueprints/types.ts +10 -2
  224. package/src/lib/workflows/delay.ts +106 -0
  225. package/src/lib/workflows/engine.ts +207 -4
  226. package/src/lib/workflows/loop-executor.ts +349 -24
  227. package/src/lib/workflows/post-action.ts +91 -0
  228. package/src/lib/workflows/types.ts +166 -1
  229. package/src/app/api/license/checkout/route.ts +0 -28
  230. package/src/app/api/license/portal/route.ts +0 -26
  231. package/src/app/api/license/route.ts +0 -89
  232. package/src/app/api/license/usage/route.ts +0 -63
  233. package/src/app/api/marketplace/browse/route.ts +0 -15
  234. package/src/app/api/marketplace/import/route.ts +0 -28
  235. package/src/app/api/marketplace/publish/route.ts +0 -40
  236. package/src/app/api/onboarding/email/route.ts +0 -53
  237. package/src/app/api/settings/telemetry/route.ts +0 -14
  238. package/src/app/api/sync/export/route.ts +0 -54
  239. package/src/app/api/sync/restore/route.ts +0 -37
  240. package/src/app/api/sync/sessions/route.ts +0 -24
  241. package/src/app/auth/callback/route.ts +0 -73
  242. package/src/app/marketplace/page.tsx +0 -19
  243. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  244. package/src/components/marketplace/blueprint-card.tsx +0 -61
  245. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  246. package/src/components/onboarding/email-capture-card.tsx +0 -104
  247. package/src/components/settings/activation-form.tsx +0 -95
  248. package/src/components/settings/cloud-account-section.tsx +0 -147
  249. package/src/components/settings/cloud-sync-section.tsx +0 -155
  250. package/src/components/settings/subscription-section.tsx +0 -410
  251. package/src/components/settings/telemetry-section.tsx +0 -80
  252. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  253. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  254. package/src/components/shared/upgrade-banner.tsx +0 -112
  255. package/src/hooks/use-supabase-auth.ts +0 -79
  256. package/src/lib/billing/email.ts +0 -54
  257. package/src/lib/billing/products.ts +0 -80
  258. package/src/lib/billing/stripe.ts +0 -101
  259. package/src/lib/cloud/supabase-browser.ts +0 -32
  260. package/src/lib/cloud/supabase-client.ts +0 -56
  261. package/src/lib/license/__tests__/features.test.ts +0 -56
  262. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  263. package/src/lib/license/__tests__/manager.test.ts +0 -64
  264. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  265. package/src/lib/license/cloud-validation.ts +0 -60
  266. package/src/lib/license/features.ts +0 -44
  267. package/src/lib/license/key-format.ts +0 -101
  268. package/src/lib/license/limit-check.ts +0 -111
  269. package/src/lib/license/limit-queries.ts +0 -51
  270. package/src/lib/license/manager.ts +0 -345
  271. package/src/lib/license/notifications.ts +0 -59
  272. package/src/lib/license/tier-limits.ts +0 -71
  273. package/src/lib/marketplace/marketplace-client.ts +0 -107
  274. package/src/lib/sync/cloud-sync.ts +0 -235
  275. package/src/lib/telemetry/conversion-events.ts +0 -71
  276. package/src/lib/telemetry/queue.ts +0 -122
  277. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolvePostAction,
4
+ shouldSkipPostActionValue,
5
+ extractPostActionValue,
6
+ } from "../post-action";
7
+ import type { StepPostAction } from "../types";
8
+
9
+ describe("resolvePostAction", () => {
10
+ it("substitutes {{row.id}} with the row's id field", () => {
11
+ const action: StepPostAction = {
12
+ type: "update_row",
13
+ tableId: "tbl_contacts",
14
+ rowId: "{{row.id}}",
15
+ column: "linkedin",
16
+ };
17
+ const row = { id: "row_abc", name: "Alice" };
18
+
19
+ const resolved = resolvePostAction(action, row, "row");
20
+
21
+ expect(resolved.rowId).toBe("row_abc");
22
+ expect(resolved.tableId).toBe("tbl_contacts");
23
+ expect(resolved.column).toBe("linkedin");
24
+ });
25
+
26
+ it("respects a custom itemVariable name", () => {
27
+ const action: StepPostAction = {
28
+ type: "update_row",
29
+ tableId: "tbl_x",
30
+ rowId: "{{contact.id}}",
31
+ column: "email",
32
+ };
33
+ const row = { id: "c_42" };
34
+
35
+ const resolved = resolvePostAction(action, row, "contact");
36
+
37
+ expect(resolved.rowId).toBe("c_42");
38
+ });
39
+
40
+ it("supports nested field paths like {{row.meta.id}}", () => {
41
+ const action: StepPostAction = {
42
+ type: "update_row",
43
+ tableId: "tbl_x",
44
+ rowId: "{{row.meta.id}}",
45
+ column: "value",
46
+ };
47
+ const row = { meta: { id: "nested_99" } };
48
+
49
+ const resolved = resolvePostAction(action, row, "row");
50
+
51
+ expect(resolved.rowId).toBe("nested_99");
52
+ });
53
+
54
+ it("leaves rowId untouched when no placeholder is present", () => {
55
+ const action: StepPostAction = {
56
+ type: "update_row",
57
+ tableId: "tbl_x",
58
+ rowId: "literal_row_id",
59
+ column: "value",
60
+ };
61
+
62
+ const resolved = resolvePostAction(action, { id: "ignored" }, "row");
63
+
64
+ expect(resolved.rowId).toBe("literal_row_id");
65
+ });
66
+ });
67
+
68
+ describe("shouldSkipPostActionValue", () => {
69
+ it("skips empty strings", () => {
70
+ expect(shouldSkipPostActionValue("")).toBe(true);
71
+ expect(shouldSkipPostActionValue(" ")).toBe(true);
72
+ expect(shouldSkipPostActionValue("\n\t")).toBe(true);
73
+ });
74
+
75
+ it("skips NOT_FOUND sentinel (case-insensitive)", () => {
76
+ expect(shouldSkipPostActionValue("NOT_FOUND")).toBe(true);
77
+ expect(shouldSkipPostActionValue("not_found")).toBe(true);
78
+ expect(shouldSkipPostActionValue(" NOT_FOUND ")).toBe(true);
79
+ expect(shouldSkipPostActionValue("Not_Found")).toBe(true);
80
+ });
81
+
82
+ it("does not skip real values", () => {
83
+ expect(shouldSkipPostActionValue("https://linkedin.com/in/alice")).toBe(false);
84
+ expect(shouldSkipPostActionValue("alice@example.com")).toBe(false);
85
+ expect(shouldSkipPostActionValue("0")).toBe(false);
86
+ });
87
+
88
+ it("does not skip values that merely contain NOT_FOUND as a substring", () => {
89
+ // We only skip when the trimmed value IS the sentinel; substrings stay.
90
+ expect(shouldSkipPostActionValue("Status: NOT_FOUND in registry")).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("extractPostActionValue", () => {
95
+ it("trims whitespace from the agent result", () => {
96
+ expect(extractPostActionValue(" hello ")).toBe("hello");
97
+ expect(extractPostActionValue("\nhttps://example.com\n")).toBe("https://example.com");
98
+ });
99
+
100
+ it("returns the raw string for normal values", () => {
101
+ expect(extractPostActionValue("plain")).toBe("plain");
102
+ });
103
+
104
+ it("returns empty string for null/undefined-shaped inputs", () => {
105
+ expect(extractPostActionValue(undefined)).toBe("");
106
+ expect(extractPostActionValue("")).toBe("");
107
+ });
108
+ });
@@ -2,7 +2,7 @@ import { db } from "@/lib/db";
2
2
  import { workflows } from "@/lib/db/schema";
3
3
  import { getBlueprint } from "./registry";
4
4
  import { resolveTemplate, evaluateCondition } from "./template";
5
- import type { BlueprintVariable, WorkflowBlueprint } from "./types";
5
+ import type { BlueprintVariable } from "./types";
6
6
  import type { WorkflowStep } from "../types";
7
7
 
8
8
  interface InstantiateResult {
@@ -48,6 +48,27 @@ export async function instantiateBlueprint(
48
48
  continue;
49
49
  }
50
50
 
51
+ // Delay step: a pure time wait with no prompt/profile. Blueprint validation
52
+ // enforces that delayDuration and profileId+promptTemplate are mutually
53
+ // exclusive (XOR), so branching here is safe.
54
+ if (step.delayDuration) {
55
+ resolvedSteps.push({
56
+ id: crypto.randomUUID(),
57
+ name: step.name,
58
+ prompt: "",
59
+ requiresApproval: step.requiresApproval,
60
+ delayDuration: step.delayDuration,
61
+ });
62
+ continue;
63
+ }
64
+
65
+ // Task step: profileId + promptTemplate must be present (XOR contract).
66
+ if (!step.promptTemplate) {
67
+ throw new Error(
68
+ `Blueprint step "${step.name}" has no promptTemplate — blueprint validation should have caught this.`,
69
+ );
70
+ }
71
+
51
72
  const resolvedPrompt = resolveTemplate(step.promptTemplate, resolvedVars);
52
73
 
53
74
  resolvedSteps.push({
@@ -11,10 +11,18 @@ export interface BlueprintVariable {
11
11
  max?: number;
12
12
  }
13
13
 
14
+ /**
15
+ * A blueprint step is either a task step (profileId + promptTemplate) OR a
16
+ * delay step (delayDuration only). The XOR is enforced at validation time
17
+ * by BlueprintStepSchema in src/lib/validators/blueprint.ts — at the type
18
+ * level, all three fields are optional so either shape is assignable.
19
+ */
14
20
  export interface BlueprintStep {
15
21
  name: string;
16
- profileId: string;
17
- promptTemplate: string;
22
+ profileId?: string;
23
+ promptTemplate?: string;
24
+ /** If set, this step is a pure time delay. Format: Nm|Nh|Nd|Nw. */
25
+ delayDuration?: string;
18
26
  requiresApproval: boolean;
19
27
  expectedOutput?: string;
20
28
  condition?: string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Duration parser and formatter for workflow step delays.
3
+ *
4
+ * Format: Nm (minutes), Nh (hours), Nd (days), Nw (weeks).
5
+ * Bounds: minimum 1 minute, maximum 30 days.
6
+ * Compound formats (e.g. "3d2h") are not supported.
7
+ *
8
+ * See features/workflow-step-delays.md for the spec.
9
+ */
10
+
11
+ const MS_PER_MINUTE = 60_000;
12
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
13
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
14
+ const MS_PER_WEEK = 7 * MS_PER_DAY;
15
+
16
+ const MIN_DURATION_MS = MS_PER_MINUTE;
17
+ const MAX_DURATION_MS = 30 * MS_PER_DAY;
18
+
19
+ const DURATION_PATTERN = /^(\d+)(m|h|d|w)$/;
20
+
21
+ const UNIT_MS: Record<string, number> = {
22
+ m: MS_PER_MINUTE,
23
+ h: MS_PER_HOUR,
24
+ d: MS_PER_DAY,
25
+ w: MS_PER_WEEK,
26
+ };
27
+
28
+ /**
29
+ * Parse a duration string into milliseconds.
30
+ *
31
+ * @throws if the format is invalid or the value is outside bounds.
32
+ */
33
+ export function parseDuration(input: string): number {
34
+ const match = input.match(DURATION_PATTERN);
35
+ if (!match) {
36
+ throw new Error(
37
+ `Invalid duration: "${input}". Use format: 30m, 2h, 3d, 1w`,
38
+ );
39
+ }
40
+
41
+ const value = Number.parseInt(match[1], 10);
42
+ const unit = match[2];
43
+ const ms = value * UNIT_MS[unit];
44
+
45
+ if (ms < MIN_DURATION_MS) {
46
+ throw new Error(`Duration below minimum: "${input}". Minimum is 1 minute (1m).`);
47
+ }
48
+ if (ms > MAX_DURATION_MS) {
49
+ throw new Error(`Duration above maximum: "${input}". Maximum is 30 days (30d).`);
50
+ }
51
+
52
+ return ms;
53
+ }
54
+
55
+ /**
56
+ * Result of checking whether a workflow step is a delay step or a task step.
57
+ * The engine branches on this — delay steps pause the workflow, task steps
58
+ * execute normally.
59
+ */
60
+ export type DelayCheck =
61
+ | { type: "task" }
62
+ | { type: "delay"; resumeAt: number };
63
+
64
+ /**
65
+ * Classify a workflow step as either a delay step or a task step, and compute
66
+ * the resume timestamp for delay steps.
67
+ *
68
+ * Pure function: no I/O, no side effects. The engine calls this inside the
69
+ * sequence executor loop and branches on the result. Invalid duration formats
70
+ * throw — blueprint validation (src/lib/validators/blueprint.ts) should catch
71
+ * these at the workflow-creation boundary, so any invalid duration reaching
72
+ * here is a programming error and must fail loudly.
73
+ *
74
+ * @param step Workflow step (any object with an optional delayDuration field)
75
+ * @param now Current epoch timestamp in milliseconds (injected for testability)
76
+ */
77
+ export function checkDelayStep(
78
+ step: { delayDuration?: string },
79
+ now: number,
80
+ ): DelayCheck {
81
+ if (!step.delayDuration) {
82
+ return { type: "task" };
83
+ }
84
+ return {
85
+ type: "delay",
86
+ resumeAt: now + parseDuration(step.delayDuration),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Format a millisecond duration back into the canonical string form.
92
+ * Prefers the largest unit that divides cleanly; falls back to minutes
93
+ * when no larger unit divides evenly.
94
+ */
95
+ export function formatDuration(ms: number): string {
96
+ if (ms >= MS_PER_WEEK && ms % MS_PER_WEEK === 0) {
97
+ return `${ms / MS_PER_WEEK}w`;
98
+ }
99
+ if (ms >= MS_PER_DAY && ms % MS_PER_DAY === 0) {
100
+ return `${ms / MS_PER_DAY}d`;
101
+ }
102
+ if (ms >= MS_PER_HOUR && ms % MS_PER_HOUR === 0) {
103
+ return `${ms / MS_PER_HOUR}h`;
104
+ }
105
+ return `${ms / MS_PER_MINUTE}m`;
106
+ }
@@ -1,11 +1,12 @@
1
1
  import { db } from "@/lib/db";
2
2
  import { workflows, tasks, agentLogs, notifications } from "@/lib/db/schema";
3
- import { eq } from "drizzle-orm";
3
+ import { and, eq } from "drizzle-orm";
4
4
  import { executeTaskWithRuntime } from "@/lib/agents/runtime";
5
5
  import { classifyTaskProfile } from "@/lib/agents/router";
6
6
  import type { WorkflowDefinition, WorkflowState, StepState, LoopState } from "./types";
7
7
  import { createInitialState } from "./types";
8
8
  import { executeLoop } from "./loop-executor";
9
+ import { checkDelayStep } from "./delay";
9
10
  import {
10
11
  buildParallelSynthesisPrompt,
11
12
  getParallelWorkflowStructure,
@@ -138,6 +139,24 @@ export async function executeWorkflow(workflowId: string): Promise<void> {
138
139
  break;
139
140
  }
140
141
 
142
+ // A delay step may have paused the workflow. The sequence executor already
143
+ // persisted the paused state and wrote resume_at; we just need to log the
144
+ // pause and return without marking the workflow "completed".
145
+ if (state.status === "paused") {
146
+ await db.insert(agentLogs).values({
147
+ id: crypto.randomUUID(),
148
+ taskId: null,
149
+ agentType: "workflow-engine",
150
+ event: "workflow_paused_for_delay",
151
+ payload: JSON.stringify({
152
+ workflowId,
153
+ delayedStepIndex: state.currentStepIndex,
154
+ }),
155
+ timestamp: new Date(),
156
+ });
157
+ return;
158
+ }
159
+
141
160
  state.status = "completed";
142
161
  state.completedAt = new Date().toISOString();
143
162
  await updateWorkflowState(workflowId, state, "completed");
@@ -179,20 +198,56 @@ export async function executeWorkflow(workflowId: string): Promise<void> {
179
198
 
180
199
  /**
181
200
  * Sequence pattern: execute steps one after another, passing output forward.
201
+ *
202
+ * @param fromStepIndex Start index for resuming after a delay. Defaults to 0
203
+ * for fresh executions. resumeWorkflow passes the index
204
+ * of the step after the one that was delayed.
182
205
  */
183
206
  async function executeSequence(
184
207
  workflowId: string,
185
208
  definition: WorkflowDefinition,
186
209
  state: WorkflowState,
187
210
  parentTaskId?: string,
188
- workflowRuntimeId?: string
211
+ workflowRuntimeId?: string,
212
+ fromStepIndex: number = 0,
189
213
  ): Promise<void> {
190
214
  let previousOutput = "";
191
215
 
192
- for (let i = 0; i < definition.steps.length; i++) {
216
+ for (let i = fromStepIndex; i < definition.steps.length; i++) {
193
217
  const step = definition.steps[i];
194
218
  state.currentStepIndex = i;
195
219
 
220
+ // Delay step: pause the workflow and return. The scheduler tick will call
221
+ // resumeWorkflow when workflows.resume_at <= now(). See features/workflow-step-delays.md.
222
+ const delayCheck = checkDelayStep(step, Date.now());
223
+ if (delayCheck.type === "delay") {
224
+ state.stepStates[i].status = "delayed";
225
+ state.stepStates[i].startedAt = new Date().toISOString();
226
+ state.status = "paused";
227
+ await updateWorkflowState(workflowId, state, "paused");
228
+ // Write resume_at to the indexed workflows column so the scheduler tick
229
+ // can find this workflow efficiently via the partial index.
230
+ await db
231
+ .update(workflows)
232
+ .set({ resumeAt: delayCheck.resumeAt, updatedAt: new Date() })
233
+ .where(eq(workflows.id, workflowId));
234
+ await db.insert(agentLogs).values({
235
+ id: crypto.randomUUID(),
236
+ taskId: null,
237
+ agentType: "workflow-engine",
238
+ event: "step_delayed",
239
+ payload: JSON.stringify({
240
+ workflowId,
241
+ stepId: step.id,
242
+ stepName: step.name,
243
+ delayDuration: step.delayDuration,
244
+ resumeAt: delayCheck.resumeAt,
245
+ }),
246
+ timestamp: new Date(),
247
+ });
248
+ return;
249
+ }
250
+
196
251
  // Build prompt with context from previous step
197
252
  const contextPrompt = previousOutput
198
253
  ? `Previous step output:\n${previousOutput}\n\n---\n\n${step.prompt}`
@@ -1101,7 +1156,20 @@ export async function updateWorkflowState(
1101
1156
 
1102
1157
  if (!workflow) throw new Error(`Workflow ${workflowId} not found — cannot update state`);
1103
1158
 
1104
- const definition = JSON.parse(workflow.definition);
1159
+ // Defensive parse: a workflow row with a missing or corrupted definition
1160
+ // should not crash the engine. We still write the new _state on top, so a
1161
+ // recoverable run can continue even from a partially-written row.
1162
+ let definition: Record<string, unknown> = {};
1163
+ if (workflow.definition) {
1164
+ try {
1165
+ definition = JSON.parse(workflow.definition);
1166
+ } catch (err) {
1167
+ console.error(
1168
+ `[workflow-engine] Failed to parse definition for ${workflowId}, writing fresh state:`,
1169
+ err
1170
+ );
1171
+ }
1172
+ }
1105
1173
  const combined = { ...definition, _state: state };
1106
1174
 
1107
1175
  await db
@@ -1114,6 +1182,141 @@ export async function updateWorkflowState(
1114
1182
  .where(eq(workflows.id, workflowId));
1115
1183
  }
1116
1184
 
1185
+ /**
1186
+ * Resume a workflow that was paused at a delay step.
1187
+ *
1188
+ * Called by the scheduler tick when workflows.resume_at <= now(), and also
1189
+ * by the manual POST /api/workflows/[id]/resume endpoint when the user clicks
1190
+ * "Resume Now". The status transition is atomic (UPDATE ... WHERE status='paused')
1191
+ * so a scheduler tick and a user click racing each other produces exactly one
1192
+ * resume — the loser sees zero affected rows and returns silently.
1193
+ *
1194
+ * Only supports sequence-pattern workflows — other patterns never enter the
1195
+ * paused state in the first place (delay steps are sequence-only per spec).
1196
+ */
1197
+ export async function resumeWorkflow(workflowId: string): Promise<void> {
1198
+ // Atomic status transition: only proceed if still paused.
1199
+ const updated = await db
1200
+ .update(workflows)
1201
+ .set({ status: "active", resumeAt: null, updatedAt: new Date() })
1202
+ .where(and(eq(workflows.id, workflowId), eq(workflows.status, "paused")))
1203
+ .returning();
1204
+
1205
+ if (updated.length === 0) {
1206
+ // Workflow is not paused — either already resumed (scheduler raced user,
1207
+ // or vice versa) or doesn't exist. Idempotent: no error, no action.
1208
+ return;
1209
+ }
1210
+
1211
+ const workflow = updated[0];
1212
+ const { definition, state } = parseWorkflowState(workflow.definition);
1213
+
1214
+ if (!state) {
1215
+ throw new Error(
1216
+ `Workflow ${workflowId} is marked paused but has no persisted state to resume`,
1217
+ );
1218
+ }
1219
+
1220
+ if (definition.pattern !== "sequence") {
1221
+ throw new Error(
1222
+ `Workflow ${workflowId} has pattern "${definition.pattern}" — resume is only supported for sequence pattern`,
1223
+ );
1224
+ }
1225
+
1226
+ // Mark the delayed step as completed, advance to the next step.
1227
+ const delayedIdx = state.currentStepIndex;
1228
+ const delayedStepState = state.stepStates[delayedIdx];
1229
+ if (delayedStepState && delayedStepState.status === "delayed") {
1230
+ delayedStepState.status = "completed";
1231
+ delayedStepState.completedAt = new Date().toISOString();
1232
+ }
1233
+ state.status = "running";
1234
+ const resumeFromIndex = delayedIdx + 1;
1235
+
1236
+ await db.insert(agentLogs).values({
1237
+ id: crypto.randomUUID(),
1238
+ taskId: null,
1239
+ agentType: "workflow-engine",
1240
+ event: "workflow_resumed",
1241
+ payload: JSON.stringify({ workflowId, resumeFromIndex }),
1242
+ timestamp: new Date(),
1243
+ });
1244
+
1245
+ const parentTaskId = definition.sourceTaskId;
1246
+ const workflowRuntimeId = workflow.runtimeId ?? undefined;
1247
+
1248
+ // Reopen the learning session for this resume. Context proposals gathered
1249
+ // during the pre-pause run were already flushed when the original execute
1250
+ // closed its session; resume starts a fresh batch.
1251
+ openLearningSession(workflowId);
1252
+
1253
+ try {
1254
+ await executeSequence(
1255
+ workflowId,
1256
+ definition,
1257
+ state,
1258
+ parentTaskId,
1259
+ workflowRuntimeId,
1260
+ resumeFromIndex,
1261
+ );
1262
+
1263
+ // Another delay step may have been encountered during resume. TS narrows
1264
+ // state.status to "running" at this point because of the assignment above,
1265
+ // but executeSequence mutates state.status to "paused" when it hits a delay
1266
+ // step — the `as` cast forces TS to forget the narrowing and evaluate the
1267
+ // comparison at runtime.
1268
+ if ((state.status as WorkflowState["status"]) === "paused") {
1269
+ await db.insert(agentLogs).values({
1270
+ id: crypto.randomUUID(),
1271
+ taskId: null,
1272
+ agentType: "workflow-engine",
1273
+ event: "workflow_paused_for_delay",
1274
+ payload: JSON.stringify({
1275
+ workflowId,
1276
+ delayedStepIndex: state.currentStepIndex,
1277
+ }),
1278
+ timestamp: new Date(),
1279
+ });
1280
+ return;
1281
+ }
1282
+
1283
+ state.status = "completed";
1284
+ state.completedAt = new Date().toISOString();
1285
+ await updateWorkflowState(workflowId, state, "completed");
1286
+
1287
+ await db.insert(agentLogs).values({
1288
+ id: crypto.randomUUID(),
1289
+ taskId: null,
1290
+ agentType: "workflow-engine",
1291
+ event: "workflow_completed",
1292
+ payload: JSON.stringify({ workflowId }),
1293
+ timestamp: new Date(),
1294
+ });
1295
+ } catch (error) {
1296
+ state.status = "failed";
1297
+ await updateWorkflowState(workflowId, state, "failed");
1298
+
1299
+ await db.insert(agentLogs).values({
1300
+ id: crypto.randomUUID(),
1301
+ taskId: null,
1302
+ agentType: "workflow-engine",
1303
+ event: "workflow_failed",
1304
+ payload: JSON.stringify({
1305
+ workflowId,
1306
+ error: error instanceof Error ? error.message : String(error),
1307
+ }),
1308
+ timestamp: new Date(),
1309
+ });
1310
+ } finally {
1311
+ updateExecutionStats(workflowId).catch((err) => {
1312
+ console.error("[workflow-engine] Stats update failed:", err);
1313
+ });
1314
+ await closeLearningSession(workflowId).catch((err) => {
1315
+ console.error("[workflow-engine] Failed to close learning session:", err);
1316
+ });
1317
+ }
1318
+ }
1319
+
1117
1320
  /**
1118
1321
  * Get the current state of a workflow.
1119
1322
  */