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,53 @@
1
+ import { getSettingSync } from "@/lib/settings/helpers";
2
+ import { SETTINGS_KEYS } from "@/lib/constants/settings";
3
+
4
+ const DEFAULT_MAX_CONCURRENT = 2;
5
+ const DEFAULT_MAX_RUN_DURATION_SEC = 1200; // 20 minutes
6
+ const DEFAULT_CHAT_PRESSURE_DELAY_SEC = 30;
7
+
8
+ function readIntConfig(
9
+ envVar: string,
10
+ settingKey: string,
11
+ defaultValue: number,
12
+ ): number {
13
+ const envRaw = process.env[envVar];
14
+ if (envRaw !== undefined) {
15
+ const parsed = parseInt(envRaw, 10);
16
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
17
+ console.warn(
18
+ `[schedule-config] ${envVar}="${envRaw}" is not a positive integer; using default ${defaultValue}`,
19
+ );
20
+ }
21
+
22
+ const settingRaw = getSettingSync(settingKey);
23
+ if (settingRaw !== null) {
24
+ const parsed = parseInt(settingRaw, 10);
25
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
26
+ }
27
+
28
+ return defaultValue;
29
+ }
30
+
31
+ export function getScheduleMaxConcurrent(): number {
32
+ return readIntConfig(
33
+ "SCHEDULE_MAX_CONCURRENT",
34
+ SETTINGS_KEYS.SCHEDULE_MAX_CONCURRENT,
35
+ DEFAULT_MAX_CONCURRENT,
36
+ );
37
+ }
38
+
39
+ export function getScheduleMaxRunDurationSec(): number {
40
+ return readIntConfig(
41
+ "SCHEDULE_MAX_RUN_DURATION_SEC",
42
+ SETTINGS_KEYS.SCHEDULE_MAX_RUN_DURATION_SEC,
43
+ DEFAULT_MAX_RUN_DURATION_SEC,
44
+ );
45
+ }
46
+
47
+ export function getScheduleChatPressureDelaySec(): number {
48
+ return readIntConfig(
49
+ "SCHEDULE_CHAT_PRESSURE_DELAY_SEC",
50
+ SETTINGS_KEYS.SCHEDULE_CHAT_PRESSURE_DELAY_SEC,
51
+ DEFAULT_CHAT_PRESSURE_DELAY_SEC,
52
+ );
53
+ }
@@ -12,8 +12,9 @@
12
12
  */
13
13
 
14
14
  import { db } from "@/lib/db";
15
- import { schedules, tasks, agentLogs, scheduleDocumentInputs, documents } from "@/lib/db/schema";
16
- import { eq, and, lte, inArray, sql, asc } from "drizzle-orm";
15
+ import { schedules, tasks, agentLogs, scheduleDocumentInputs, documents, workflows, scheduleFiringMetrics } from "@/lib/db/schema";
16
+ import { eq, and, lte, inArray, sql, asc, isNotNull } from "drizzle-orm";
17
+ import { resumeWorkflow } from "@/lib/workflows/engine";
17
18
  import { computeNextFireTime } from "./interval-parser";
18
19
  import { executeTaskWithRuntime } from "@/lib/agents/runtime";
19
20
  import { getSetting } from "@/lib/settings/helpers";
@@ -27,6 +28,13 @@ import {
27
28
  import { sendToChannels } from "@/lib/channels/registry";
28
29
  import type { ChannelMessage } from "@/lib/channels/types";
29
30
  import { processHandoffs } from "@/lib/agents/handoff/bus";
31
+ import { claimSlot, reapExpiredLeases, countRunningScheduledSlots } from "./slot-claim";
32
+ import { isAnyChatStreaming } from "@/lib/chat/active-streams";
33
+ import {
34
+ getScheduleMaxConcurrent,
35
+ getScheduleMaxRunDurationSec,
36
+ getScheduleChatPressureDelaySec,
37
+ } from "./config";
30
38
 
31
39
  const POLL_INTERVAL_MS = 60_000; // 60 seconds
32
40
 
@@ -54,6 +62,10 @@ export async function drainQueue(): Promise<void> {
54
62
  // Loop until the queue is empty so a single drain cycle clears all
55
63
  // collided tasks rather than only the next one.
56
64
  while (true) {
65
+ // Respect the global cap — stop draining if we're already at capacity
66
+ const cap = getScheduleMaxConcurrent();
67
+ if (countRunningScheduledSlots() >= cap) return;
68
+
57
69
  const [nextQueued] = await db
58
70
  .select({ id: tasks.id })
59
71
  .from(tasks)
@@ -68,7 +80,17 @@ export async function drainQueue(): Promise<void> {
68
80
 
69
81
  if (!nextQueued) return;
70
82
 
71
- console.log(`[scheduler] draining queue executing task ${nextQueued.id}`);
83
+ // Atomic claim could lose the race if a concurrent tick already took
84
+ // this specific task, OR the cap filled between the select and the claim.
85
+ // On a lost-race (task-level) we should try the next queued task; on a
86
+ // cap-full the next iteration's cap check at the top of the loop will
87
+ // return. Continue rather than return so we don't strand other queued
88
+ // tasks that could still claim.
89
+ const leaseSec = getScheduleMaxRunDurationSec();
90
+ const { claimed } = claimSlot(nextQueued.id, cap, leaseSec);
91
+ if (!claimed) continue;
92
+
93
+ console.log(`[scheduler] draining queue → running task ${nextQueued.id}`);
72
94
  try {
73
95
  await executeTaskWithRuntime(nextQueued.id);
74
96
  } catch (err) {
@@ -131,17 +153,31 @@ function detectFailureReason(result: string | null): string {
131
153
  return "error";
132
154
  }
133
155
 
156
+ const TURN_BUDGET_BREACH_AUTO_PAUSE_THRESHOLD = 5;
157
+ const GRACE_PERIOD_MULTIPLIER = 2; // grace window = 2 × cron interval
158
+
134
159
  /**
135
160
  * Record per-firing health metrics on a schedule and auto-pause after
136
- * 3 consecutive failures. Uses an exponential moving average for turn count
137
- * so the metric reflects recent behavior more than ancient firings.
161
+ * 3 consecutive generic failures or 5 consecutive turn-budget breaches.
162
+ * Uses an exponential moving average for turn count so the metric reflects
163
+ * recent behavior more than ancient firings.
164
+ *
165
+ * Turn-budget breaches are tracked separately (turnBudgetBreachStreak) so a
166
+ * misconfigured maxTurns doesn't auto-pause via the generic threshold of 3.
167
+ * A first-breach grace window (2× cron interval after maxTurnsSetAt) forgives
168
+ * the first firing that hits a newly-lowered cap.
138
169
  */
139
170
  export async function recordFiringMetrics(
140
171
  scheduleId: string,
141
- taskId: string
172
+ taskId: string,
142
173
  ): Promise<void> {
143
174
  const [task] = await db
144
- .select({ status: tasks.status, result: tasks.result })
175
+ .select({
176
+ status: tasks.status,
177
+ result: tasks.result,
178
+ failureReason: tasks.failureReason,
179
+ updatedAt: tasks.updatedAt,
180
+ })
145
181
  .from(tasks)
146
182
  .where(eq(tasks.id, taskId));
147
183
  if (!task) return;
@@ -162,26 +198,124 @@ export async function recordFiringMetrics(
162
198
  const newAvg = Math.round(prevAvg * 0.7 + turns * 0.3);
163
199
 
164
200
  const isFailure = task.status === "failed";
165
- const newStreak = isFailure ? (schedule.failureStreak ?? 0) + 1 : 0;
166
- const shouldAutoPause = isFailure && newStreak >= 3 && schedule.status === "active";
201
+ const failureReason =
202
+ task.failureReason ?? (isFailure ? detectFailureReason(task.result) : null);
203
+ const isTurnBudgetBreach = failureReason === "turn_limit_exceeded";
204
+ const isGenericFailure = isFailure && !isTurnBudgetBreach;
205
+
206
+ // First-breach grace: if this is the first firing after maxTurns was edited,
207
+ // don't count the breach toward the auto-pause streak.
208
+ let turnBudgetStreakDelta = 0;
209
+ if (isTurnBudgetBreach) {
210
+ const graceApplies = shouldApplyGrace(
211
+ schedule.maxTurnsSetAt,
212
+ schedule.cronExpression,
213
+ task.updatedAt,
214
+ );
215
+ if (!graceApplies) turnBudgetStreakDelta = 1;
216
+ }
217
+
218
+ const newFailureStreak = isGenericFailure ? (schedule.failureStreak ?? 0) + 1 : 0;
219
+ const newBudgetStreak =
220
+ turnBudgetStreakDelta > 0
221
+ ? (schedule.turnBudgetBreachStreak ?? 0) + 1
222
+ : isTurnBudgetBreach
223
+ ? (schedule.turnBudgetBreachStreak ?? 0) // hold-steady but coerce null→0
224
+ : 0;
225
+ const shouldAutoPauseGeneric =
226
+ isGenericFailure && newFailureStreak >= 3 && schedule.status === "active";
227
+ const shouldAutoPauseBudget =
228
+ newBudgetStreak >= TURN_BUDGET_BREACH_AUTO_PAUSE_THRESHOLD &&
229
+ schedule.status === "active";
230
+ const shouldAutoPause = shouldAutoPauseGeneric || shouldAutoPauseBudget;
167
231
 
168
232
  await db
169
233
  .update(schedules)
170
234
  .set({
171
235
  lastTurnCount: turns,
172
236
  avgTurnsPerFiring: newAvg,
173
- failureStreak: newStreak,
174
- lastFailureReason: isFailure ? detectFailureReason(task.result) : null,
237
+ failureStreak: newFailureStreak,
238
+ turnBudgetBreachStreak: newBudgetStreak,
239
+ lastFailureReason: failureReason,
175
240
  status: shouldAutoPause ? "paused" : schedule.status,
176
241
  updatedAt: new Date(),
177
242
  })
178
243
  .where(eq(schedules.id, scheduleId));
179
244
 
180
- if (shouldAutoPause) {
245
+ if (shouldAutoPauseGeneric) {
246
+ console.warn(
247
+ `[scheduler] auto-paused "${schedule.name}" after 3 consecutive failures`,
248
+ );
249
+ }
250
+ if (shouldAutoPauseBudget) {
181
251
  console.warn(
182
- `[scheduler] auto-paused "${schedule.name}" after 3 consecutive failures`
252
+ `[scheduler] auto-paused "${schedule.name}" after 5 consecutive turn-budget breaches (avg: ${newAvg} steps, cap: ${schedule.maxTurns})`,
183
253
  );
184
254
  }
255
+
256
+ try {
257
+ const [taskRow] = await db
258
+ .select()
259
+ .from(tasks)
260
+ .where(eq(tasks.id, taskId));
261
+ if (taskRow) {
262
+ const firedAtDate = taskRow.createdAt;
263
+ const slotClaimedAt = taskRow.slotClaimedAt;
264
+ const completedAt = taskRow.updatedAt;
265
+ const slotWaitMs =
266
+ slotClaimedAt && firedAtDate
267
+ ? slotClaimedAt.getTime() - firedAtDate.getTime()
268
+ : null;
269
+ const durationMs =
270
+ slotClaimedAt && completedAt
271
+ ? completedAt.getTime() - slotClaimedAt.getTime()
272
+ : null;
273
+
274
+ await db.insert(scheduleFiringMetrics).values({
275
+ id: crypto.randomUUID(),
276
+ scheduleId,
277
+ taskId,
278
+ firedAt: firedAtDate,
279
+ slotClaimedAt,
280
+ completedAt,
281
+ slotWaitMs,
282
+ durationMs,
283
+ turnCount: turns,
284
+ maxTurnsAtFiring: schedule.maxTurns,
285
+ eventLoopLagMs: null,
286
+ peakRssMb: null,
287
+ chatStreamsActive: null,
288
+ concurrentSchedules: null,
289
+ failureReason,
290
+ });
291
+ }
292
+ } catch (err) {
293
+ console.error(`[scheduler] failed to insert firing metrics for ${taskId}:`, err);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * First-breach grace: if maxTurnsSetAt was recent enough that this is the
299
+ * first-or-second firing after the edit, don't count the breach toward the
300
+ * auto-pause streak.
301
+ */
302
+ function shouldApplyGrace(
303
+ maxTurnsSetAt: Date | null,
304
+ cronExpression: string,
305
+ completedAt: Date | null,
306
+ ): boolean {
307
+ if (!maxTurnsSetAt || !completedAt) return false;
308
+ try {
309
+ const t1 = computeNextFireTime(cronExpression, maxTurnsSetAt);
310
+ const t2 = computeNextFireTime(cronExpression, t1);
311
+ const cronIntervalMs = t2.getTime() - t1.getTime();
312
+ const graceWindowEnd = new Date(
313
+ maxTurnsSetAt.getTime() + GRACE_PERIOD_MULTIPLIER * cronIntervalMs,
314
+ );
315
+ return completedAt <= graceWindowEnd;
316
+ } catch {
317
+ return false;
318
+ }
185
319
  }
186
320
 
187
321
  /**
@@ -220,6 +354,18 @@ export function stopScheduler(): void {
220
354
  export async function tickScheduler(): Promise<void> {
221
355
  const now = new Date();
222
356
 
357
+ // Reap any running tasks whose lease has expired before claiming new slots.
358
+ try {
359
+ const reaped = reapExpiredLeases();
360
+ if (reaped.length > 0) {
361
+ console.warn(
362
+ `[scheduler] reaped ${reaped.length} expired lease(s): ${reaped.join(", ")}`,
363
+ );
364
+ }
365
+ } catch (err) {
366
+ console.error("[scheduler] lease reaper error:", err);
367
+ }
368
+
223
369
  const dueSchedules = await db
224
370
  .select()
225
371
  .from(schedules)
@@ -230,6 +376,34 @@ export async function tickScheduler(): Promise<void> {
230
376
  )
231
377
  );
232
378
 
379
+ // Chat soft pressure: defer new firings by N seconds when any chat stream
380
+ // is in flight. In-flight scheduled runs are NOT affected — this only gates
381
+ // new claims. Per-iteration try/catch so a single failed deferral doesn't
382
+ // silently skip the remaining schedules in this tick.
383
+ if (isAnyChatStreaming() && dueSchedules.length > 0) {
384
+ const delayMs = getScheduleChatPressureDelaySec() * 1000;
385
+ const deferredUntil = new Date(now.getTime() + delayMs);
386
+ let deferredCount = 0;
387
+ for (const schedule of dueSchedules) {
388
+ try {
389
+ await db
390
+ .update(schedules)
391
+ .set({ nextFireAt: deferredUntil, updatedAt: now })
392
+ .where(eq(schedules.id, schedule.id));
393
+ deferredCount++;
394
+ } catch (err) {
395
+ console.error(
396
+ `[scheduler] failed to defer schedule ${schedule.id} under chat pressure:`,
397
+ err,
398
+ );
399
+ }
400
+ }
401
+ console.warn(
402
+ `[scheduler] chat streaming — deferred ${deferredCount}/${dueSchedules.length} firings by ${delayMs}ms`,
403
+ );
404
+ return;
405
+ }
406
+
233
407
  for (const schedule of dueSchedules) {
234
408
  try {
235
409
  // Atomic claim: attempt to update nextFireAt to null as a lock.
@@ -268,6 +442,32 @@ export async function tickScheduler(): Promise<void> {
268
442
  } catch (err) {
269
443
  console.error("[scheduler] handoff processing error:", err);
270
444
  }
445
+
446
+ // Resume delayed workflows whose resume_at has passed. Uses the partial index
447
+ // idx_workflows_resume_at (WHERE resume_at IS NOT NULL) for efficiency.
448
+ // resumeWorkflow is idempotent via atomic status transition, so even if the
449
+ // scheduler tick races a user's "Resume Now" click, exactly one resume wins.
450
+ try {
451
+ const nowMs = now.getTime();
452
+ const dueDelayedWorkflows = await db
453
+ .select({ id: workflows.id })
454
+ .from(workflows)
455
+ .where(
456
+ and(
457
+ eq(workflows.status, "paused"),
458
+ isNotNull(workflows.resumeAt),
459
+ lte(workflows.resumeAt, nowMs),
460
+ ),
461
+ );
462
+
463
+ for (const wf of dueDelayedWorkflows) {
464
+ resumeWorkflow(wf.id).catch((err) => {
465
+ console.error(`[scheduler] failed to resume workflow ${wf.id}:`, err);
466
+ });
467
+ }
468
+ } catch (err) {
469
+ console.error("[scheduler] delayed-workflow check error:", err);
470
+ }
271
471
  }
272
472
 
273
473
  async function fireSchedule(
@@ -332,6 +532,7 @@ async function fireSchedule(
332
532
  agentProfile: schedule.agentProfile,
333
533
  priority: 2,
334
534
  sourceType: "scheduled",
535
+ maxTurns: schedule.maxTurns, // per-schedule override, NULL = inherit global
335
536
  createdAt: now,
336
537
  updatedAt: now,
337
538
  });
@@ -378,6 +579,23 @@ async function fireSchedule(
378
579
  })
379
580
  .where(eq(schedules.id, schedule.id));
380
581
 
582
+ // Atomic slot claim — if the global cap is full, leave the task in queued
583
+ // state. The task will be picked up by drainQueue when a currently-running
584
+ // task completes (its .then(drainQueue) chain runs the drain loop), OR by
585
+ // the next tickScheduler pass up to POLL_INTERVAL_MS (60s) later — whichever
586
+ // comes first. In a saturated-cap scenario where no running task completes
587
+ // before the next poll, expect up to a 60s drain latency.
588
+ const cap = getScheduleMaxConcurrent();
589
+ const leaseSec = schedule.maxRunDurationSec ?? getScheduleMaxRunDurationSec();
590
+ const { claimed } = claimSlot(taskId, cap, leaseSec);
591
+
592
+ if (!claimed) {
593
+ console.warn(
594
+ `[scheduler] schedule "${schedule.name}" queued — cap full (${countRunningScheduledSlots()}/${cap})`,
595
+ );
596
+ return;
597
+ }
598
+
381
599
  // Drain-aware task execution. We still don't await in fireSchedule (the
382
600
  // poll loop must keep claiming other due schedules), but on completion we
383
601
  // record metrics and trigger drainQueue() so any tasks queued by colliding
@@ -510,6 +728,7 @@ async function fireHeartbeat(
510
728
  agentProfile: schedule.agentProfile,
511
729
  priority: 2,
512
730
  sourceType: "heartbeat",
731
+ maxTurns: schedule.maxTurns, // per-schedule override, NULL = inherit global
513
732
  createdAt: now,
514
733
  updatedAt: now,
515
734
  });
@@ -0,0 +1,105 @@
1
+ import { sqlite } from "@/lib/db";
2
+ import { getExecution } from "@/lib/agents/execution-manager";
3
+
4
+ export interface ClaimResult {
5
+ claimed: boolean;
6
+ }
7
+
8
+ // Module-level prepared statements. These are hot-path primitives called on
9
+ // every scheduler tick and drain pass, so we pay the SQL compilation cost once
10
+ // at module load rather than on every invocation.
11
+ const claimStmt = sqlite.prepare(
12
+ "UPDATE tasks SET status = 'running', slot_claimed_at = ?, lease_expires_at = ?, updated_at = ? WHERE id = ? AND status = 'queued' AND source_type IN ('scheduled', 'heartbeat') AND (SELECT COUNT(*) FROM tasks WHERE status = 'running' AND source_type IN ('scheduled', 'heartbeat')) < ?",
13
+ );
14
+
15
+ const countRunningStmt = sqlite.prepare(
16
+ "SELECT COUNT(*) AS n FROM tasks WHERE status = 'running' AND source_type IN ('scheduled', 'heartbeat')",
17
+ );
18
+
19
+ /**
20
+ * Atomic slot claim: transitions a queued scheduled task to running IFF the
21
+ * global cap of concurrent running scheduled tasks is not exceeded.
22
+ *
23
+ * Must be a single SQL statement — check-then-act would race between the
24
+ * scheduler tick loop and the drain loop that scheduler.ts currently dispatches
25
+ * concurrently. Using a subquery inside the WHERE clause guarantees SQLite
26
+ * serializes the count and update under its write lock, so two concurrent
27
+ * claim attempts cannot both succeed against the same cap.
28
+ *
29
+ * Returns `{ claimed: true }` when the task transitioned; `{ claimed: false }`
30
+ * when either (a) the task is no longer in queued state (already claimed) or
31
+ * (b) the global cap is full.
32
+ *
33
+ * @param cap — must be ≥ 0. Negative values refuse to claim (the SQL COUNT
34
+ * cannot be less than a negative number) — treat as an input error upstream.
35
+ * @param leaseSec — must be > 0 for the lease to be meaningful. A lease of 0
36
+ * expires immediately and will be reaped on the next scheduler tick.
37
+ */
38
+ export function claimSlot(
39
+ taskId: string,
40
+ cap: number,
41
+ leaseSec: number,
42
+ ): ClaimResult {
43
+ // Drizzle integer({ mode: "timestamp" }) stores Unix seconds and deserializes
44
+ // to Date(seconds * 1000). Pass seconds here so the round-trip is correct.
45
+ // Use Math.ceil so slotClaimedAt.getTime() >= Date.now() captured just before
46
+ // the call (sub-second precision would cause test assertions to fail with floor).
47
+ const nowSec = Math.ceil(Date.now() / 1000);
48
+ const leaseExpiresSec = nowSec + leaseSec;
49
+
50
+ const result = claimStmt.run(nowSec, leaseExpiresSec, nowSec, taskId, cap);
51
+ return { claimed: result.changes === 1 };
52
+ }
53
+
54
+ /**
55
+ * Count currently running scheduled/heartbeat tasks — used by the drain loop,
56
+ * manual-execute endpoint, and telemetry.
57
+ */
58
+ export function countRunningScheduledSlots(): number {
59
+ const row = countRunningStmt.get() as { n: number } | undefined;
60
+ return row?.n ?? 0;
61
+ }
62
+
63
+ // Module-level prepared statements for the reaper hot path
64
+ const selectExpiredStmt = sqlite.prepare(
65
+ "SELECT id FROM tasks WHERE status = 'running' AND source_type IN ('scheduled', 'heartbeat') AND lease_expires_at IS NOT NULL AND lease_expires_at < ?",
66
+ );
67
+
68
+ const reapUpdateStmt = sqlite.prepare(
69
+ "UPDATE tasks SET status = 'failed', failure_reason = 'lease_expired', updated_at = ? WHERE id = ? AND status = 'running'",
70
+ );
71
+
72
+ /**
73
+ * Reap running scheduled tasks whose lease has expired. For each expired
74
+ * task: (1) abort the in-memory execution via AbortController, (2) mark
75
+ * the DB row as failed with failure_reason='lease_expired'. Returns the
76
+ * list of reaped task IDs for logging.
77
+ *
78
+ * Idempotent — safe to call on every scheduler tick.
79
+ */
80
+ export function reapExpiredLeases(): string[] {
81
+ // Drizzle mode: "timestamp" stores seconds; raw SQL comparisons must use
82
+ // seconds. Use Math.floor (strict < comparison, so floor catches everything
83
+ // already past).
84
+ const nowSec = Math.floor(Date.now() / 1000);
85
+
86
+ const expiredRows = selectExpiredStmt.all(nowSec) as Array<{ id: string }>;
87
+
88
+ const reaped: string[] = [];
89
+ for (const { id } of expiredRows) {
90
+ // Abort the in-process execution so the SDK stops immediately
91
+ const execution = getExecution(id);
92
+ if (execution) {
93
+ try {
94
+ execution.abortController.abort();
95
+ } catch {
96
+ // Already aborted — safe to ignore
97
+ }
98
+ }
99
+
100
+ const updateResult = reapUpdateStmt.run(nowSec, id);
101
+ if (updateResult.changes === 1) reaped.push(id);
102
+ }
103
+
104
+ return reaped;
105
+ }
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockFrom = vi.fn();
4
+ const mockWhere = vi.fn();
5
+ const mockValues = vi.fn();
6
+ const mockSet = vi.fn();
7
+ const mockRun = vi.fn();
8
+
9
+ vi.mock("@/lib/db", () => ({
10
+ db: {
11
+ select: () => ({ from: mockFrom }),
12
+ insert: () => ({ values: mockValues }),
13
+ update: () => ({ set: mockSet }),
14
+ },
15
+ }));
16
+
17
+ vi.mock("@/lib/db/schema", () => ({
18
+ settings: { key: "key" },
19
+ }));
20
+
21
+ vi.mock("@/lib/utils/crypto", () => ({
22
+ encrypt: vi.fn((v: string) => `encrypted:${v}`),
23
+ decrypt: vi.fn((v: string) => v.replace("encrypted:", "")),
24
+ }));
25
+
26
+ vi.mock("@/lib/utils/stagent-paths", () => ({
27
+ getStagentCodexAuthPath: () => "/tmp/stagent-codex/auth.json",
28
+ }));
29
+
30
+ mockFrom.mockReturnValue({ where: mockWhere });
31
+ mockValues.mockReturnValue({ run: mockRun });
32
+ mockSet.mockReturnValue({ where: vi.fn().mockReturnValue({ run: mockRun }) });
33
+
34
+ function mockGetSettingSequence(values: (string | null)[]) {
35
+ let callIndex = 0;
36
+ mockWhere.mockImplementation(() => {
37
+ const val = values[callIndex] ?? null;
38
+ callIndex++;
39
+ return val !== null ? [{ value: val }] : [];
40
+ });
41
+ }
42
+
43
+ describe("openai auth settings", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ vi.resetModules();
47
+ vi.unstubAllEnvs();
48
+ vi.stubEnv("OPENAI_API_KEY", "");
49
+ mockWhere.mockReturnValue([]);
50
+ });
51
+
52
+ it("defaults to api_key mode with no key", async () => {
53
+ const { getOpenAIAuthSettings } = await import("../openai-auth");
54
+ const result = await getOpenAIAuthSettings();
55
+ expect(result.method).toBe("api_key");
56
+ expect(result.hasKey).toBe(false);
57
+ expect(result.oauthConnected).toBe(false);
58
+ });
59
+
60
+ it("detects env-backed API key", async () => {
61
+ vi.stubEnv("OPENAI_API_KEY", "sk-openai");
62
+ mockGetSettingSequence([null, null, null, null, null, null]);
63
+ const { getOpenAIAuthSettings } = await import("../openai-auth");
64
+ const result = await getOpenAIAuthSettings();
65
+ expect(result.hasKey).toBe(true);
66
+ expect(result.apiKeySource).toBe("env");
67
+ });
68
+
69
+ it("returns oauth connection metadata when stored", async () => {
70
+ mockGetSettingSequence([
71
+ "oauth",
72
+ null,
73
+ null,
74
+ "true",
75
+ JSON.stringify({
76
+ account: { type: "chatgpt", email: "dev@example.com", planType: "pro" },
77
+ authMode: "chatgpt",
78
+ }),
79
+ JSON.stringify({
80
+ limitId: "codex",
81
+ limitName: null,
82
+ primary: { usedPercent: 25, windowDurationMins: 15, resetsAt: 1730947200 },
83
+ secondary: null,
84
+ }),
85
+ ]);
86
+
87
+ const { getOpenAIAuthSettings } = await import("../openai-auth");
88
+ const result = await getOpenAIAuthSettings();
89
+
90
+ expect(result.method).toBe("oauth");
91
+ expect(result.oauthConnected).toBe(true);
92
+ expect(result.account?.email).toBe("dev@example.com");
93
+ expect(result.rateLimits?.primary?.usedPercent).toBe(25);
94
+ });
95
+
96
+ it("stores method changes without clearing existing key data", async () => {
97
+ const { setOpenAIAuthSettings } = await import("../openai-auth");
98
+ await setOpenAIAuthSettings({ method: "oauth" });
99
+ expect(mockValues).toHaveBeenCalled();
100
+ });
101
+ });
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const fakeClient = {
4
+ request: vi.fn(),
5
+ close: vi.fn(async () => {}),
6
+ onProcessError: undefined as ((error: Error) => void) | undefined,
7
+ onNotification: undefined as
8
+ | ((notification: { method: string; params?: unknown }) => void)
9
+ | undefined,
10
+ };
11
+
12
+ vi.mock("@/lib/agents/runtime/openai-codex-auth", () => ({
13
+ connectStagentCodexClient: vi.fn(async () => fakeClient),
14
+ initializeCodexClient: vi.fn(async () => {}),
15
+ readStagentCodexAuthState: vi.fn(async () => ({
16
+ connected: false,
17
+ account: null,
18
+ rateLimits: null,
19
+ })),
20
+ }));
21
+
22
+ vi.mock("@/lib/settings/openai-auth", () => ({
23
+ clearOpenAIOAuthStatus: vi.fn(async () => {}),
24
+ }));
25
+
26
+ describe("openai login manager", () => {
27
+ beforeEach(() => {
28
+ vi.resetModules();
29
+ vi.clearAllMocks();
30
+ fakeClient.request.mockImplementation(async (method: string) => {
31
+ if (method === "account/login/start") {
32
+ return {
33
+ type: "chatgpt",
34
+ loginId: "login-1",
35
+ authUrl: "https://auth.openai.com/log-in",
36
+ };
37
+ }
38
+
39
+ if (method === "account/login/cancel") {
40
+ return {};
41
+ }
42
+
43
+ throw new Error(`Unexpected method: ${method}`);
44
+ });
45
+ });
46
+
47
+ it("returns a cancelled state when the active ChatGPT login is cancelled", async () => {
48
+ const {
49
+ startOpenAIChatGPTLogin,
50
+ cancelOpenAIChatGPTLogin,
51
+ getOpenAILoginState,
52
+ } = await import("@/lib/settings/openai-login-manager");
53
+
54
+ const started = await startOpenAIChatGPTLogin();
55
+ expect(started.phase).toBe("pending");
56
+
57
+ const cancelled = await cancelOpenAIChatGPTLogin();
58
+
59
+ expect(cancelled.phase).toBe("cancelled");
60
+ expect(cancelled.error).toBeNull();
61
+ expect(fakeClient.close).toHaveBeenCalledTimes(1);
62
+ expect(getOpenAILoginState().phase).toBe("cancelled");
63
+ });
64
+ });