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,33 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../auth", () => ({
4
+ getAuthSettings: vi.fn(async () => ({
5
+ method: "oauth",
6
+ hasKey: false,
7
+ apiKeySource: "oauth",
8
+ })),
9
+ }));
10
+
11
+ vi.mock("../openai-auth", () => ({
12
+ getOpenAIAuthSettings: vi.fn(async () => ({
13
+ method: "oauth",
14
+ hasKey: true,
15
+ apiKeySource: "db",
16
+ oauthConnected: true,
17
+ account: { type: "chatgpt", email: "dev@example.com", planType: "pro" },
18
+ rateLimits: null,
19
+ })),
20
+ }));
21
+
22
+ describe("runtime setup states", () => {
23
+ it("marks Codex App Server subscription-backed when ChatGPT auth is connected", async () => {
24
+ const { getRuntimeSetupStates } = await import("../runtime-setup");
25
+ const states = await getRuntimeSetupStates();
26
+
27
+ expect(states["openai-codex-app-server"].configured).toBe(true);
28
+ expect(states["openai-codex-app-server"].authMethod).toBe("oauth");
29
+ expect(states["openai-codex-app-server"].billingMode).toBe("subscription");
30
+ expect(states["openai-direct"].configured).toBe(true);
31
+ expect(states["openai-direct"].billingMode).toBe("usage");
32
+ });
33
+ });
@@ -1,26 +1,77 @@
1
- import { SETTINGS_KEYS, type ApiKeySource } from "@/lib/constants/settings";
1
+ import { existsSync } from "node:fs";
2
+ import { SETTINGS_KEYS, type ApiKeySource, type AuthMethod } from "@/lib/constants/settings";
2
3
  import { decrypt, encrypt } from "@/lib/utils/crypto";
4
+ import { getStagentCodexAuthPath } from "@/lib/utils/stagent-paths";
3
5
  import { getSetting, setSetting } from "./helpers";
4
6
 
7
+ export type OpenAIAccountType = "apiKey" | "chatgpt" | "chatgptAuthTokens";
8
+ export type OpenAIAuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | null;
9
+
10
+ export interface OpenAIAccountInfo {
11
+ type: OpenAIAccountType;
12
+ email?: string | null;
13
+ planType?: string | null;
14
+ }
15
+
16
+ export interface OpenAIRateLimitWindow {
17
+ usedPercent: number | null;
18
+ windowDurationMins: number | null;
19
+ resetsAt: number | null;
20
+ }
21
+
22
+ export interface OpenAIRateLimitInfo {
23
+ limitId: string | null;
24
+ limitName: string | null;
25
+ primary: OpenAIRateLimitWindow | null;
26
+ secondary: OpenAIRateLimitWindow | null;
27
+ }
28
+
5
29
  export interface OpenAIAuthSettings {
30
+ method: AuthMethod;
6
31
  hasKey: boolean;
7
- apiKeySource: ApiKeySource;
32
+ apiKeySource: Exclude<ApiKeySource, "oauth">;
33
+ oauthConnected: boolean;
34
+ account: OpenAIAccountInfo | null;
35
+ rateLimits: OpenAIRateLimitInfo | null;
8
36
  }
9
37
 
10
38
  export interface OpenAIAuthConfigInput {
11
- apiKey: string;
39
+ method: AuthMethod;
40
+ apiKey?: string;
41
+ }
42
+
43
+ interface PersistedOpenAIAccountPayload {
44
+ account: OpenAIAccountInfo | null;
45
+ authMode?: OpenAIAuthMode;
46
+ }
47
+
48
+ function parseJson<T>(raw: string | null): T | null {
49
+ if (!raw) return null;
50
+ try {
51
+ return JSON.parse(raw) as T;
52
+ } catch {
53
+ return null;
54
+ }
12
55
  }
13
56
 
14
57
  export async function getOpenAIAuthSettings(): Promise<OpenAIAuthSettings> {
58
+ const method = ((await getSetting(SETTINGS_KEYS.OPENAI_AUTH_METHOD)) as AuthMethod) ?? "api_key";
15
59
  const encryptedKey = await getSetting(SETTINGS_KEYS.OPENAI_AUTH_API_KEY);
16
60
  const storedSource = (await getSetting(
17
61
  SETTINGS_KEYS.OPENAI_AUTH_API_KEY_SOURCE
18
- )) as ApiKeySource | null;
62
+ )) as Exclude<ApiKeySource, "oauth"> | null;
63
+ const storedOauthConnected = await getSetting(SETTINGS_KEYS.OPENAI_AUTH_OAUTH_CONNECTED);
64
+ const storedAccount = parseJson<PersistedOpenAIAccountPayload>(
65
+ await getSetting(SETTINGS_KEYS.OPENAI_AUTH_ACCOUNT)
66
+ );
67
+ const storedRateLimits = parseJson<OpenAIRateLimitInfo>(
68
+ await getSetting(SETTINGS_KEYS.OPENAI_AUTH_RATE_LIMITS)
69
+ );
19
70
 
20
71
  const hasDbKey = encryptedKey !== null;
21
72
  const hasEnvKey = !!process.env.OPENAI_API_KEY;
22
73
 
23
- let apiKeySource: ApiKeySource;
74
+ let apiKeySource: Exclude<ApiKeySource, "oauth">;
24
75
  if (storedSource) {
25
76
  apiKeySource = storedSource;
26
77
  } else if (hasDbKey) {
@@ -31,20 +82,32 @@ export async function getOpenAIAuthSettings(): Promise<OpenAIAuthSettings> {
31
82
  apiKeySource = "unknown";
32
83
  }
33
84
 
85
+ const oauthConnected =
86
+ storedOauthConnected === "true" ||
87
+ (storedOauthConnected == null && existsSync(getStagentCodexAuthPath()));
88
+
34
89
  return {
90
+ method,
35
91
  hasKey: hasDbKey || hasEnvKey,
36
92
  apiKeySource,
93
+ oauthConnected,
94
+ account: storedAccount?.account ?? null,
95
+ rateLimits: storedRateLimits,
37
96
  };
38
97
  }
39
98
 
40
99
  export async function setOpenAIAuthSettings(
41
100
  input: OpenAIAuthConfigInput
42
101
  ): Promise<void> {
43
- await setSetting(
44
- SETTINGS_KEYS.OPENAI_AUTH_API_KEY,
45
- encrypt(input.apiKey)
46
- );
47
- await setSetting(SETTINGS_KEYS.OPENAI_AUTH_API_KEY_SOURCE, "db");
102
+ await setSetting(SETTINGS_KEYS.OPENAI_AUTH_METHOD, input.method);
103
+
104
+ if (input.apiKey) {
105
+ await setSetting(
106
+ SETTINGS_KEYS.OPENAI_AUTH_API_KEY,
107
+ encrypt(input.apiKey)
108
+ );
109
+ await setSetting(SETTINGS_KEYS.OPENAI_AUTH_API_KEY_SOURCE, "db");
110
+ }
48
111
  }
49
112
 
50
113
  export async function getOpenAIApiKey(): Promise<{
@@ -78,3 +141,35 @@ export async function updateOpenAIAuthStatus(
78
141
  ): Promise<void> {
79
142
  await setSetting(SETTINGS_KEYS.OPENAI_AUTH_API_KEY_SOURCE, source);
80
143
  }
144
+
145
+ export async function updateOpenAIOAuthStatus(input: {
146
+ connected: boolean;
147
+ account?: OpenAIAccountInfo | null;
148
+ authMode?: OpenAIAuthMode;
149
+ rateLimits?: OpenAIRateLimitInfo | null;
150
+ }): Promise<void> {
151
+ await setSetting(
152
+ SETTINGS_KEYS.OPENAI_AUTH_OAUTH_CONNECTED,
153
+ input.connected ? "true" : "false"
154
+ );
155
+ await setSetting(
156
+ SETTINGS_KEYS.OPENAI_AUTH_ACCOUNT,
157
+ JSON.stringify({
158
+ account: input.account ?? null,
159
+ authMode: input.authMode ?? null,
160
+ } satisfies PersistedOpenAIAccountPayload)
161
+ );
162
+ await setSetting(
163
+ SETTINGS_KEYS.OPENAI_AUTH_RATE_LIMITS,
164
+ JSON.stringify(input.rateLimits ?? null)
165
+ );
166
+ }
167
+
168
+ export async function clearOpenAIOAuthStatus(): Promise<void> {
169
+ await updateOpenAIOAuthStatus({
170
+ connected: false,
171
+ account: null,
172
+ authMode: null,
173
+ rateLimits: null,
174
+ });
175
+ }
@@ -0,0 +1,260 @@
1
+ import {
2
+ connectStagentCodexClient,
3
+ initializeCodexClient,
4
+ readStagentCodexAuthState,
5
+ } from "@/lib/agents/runtime/openai-codex-auth";
6
+ import { clearOpenAIOAuthStatus } from "./openai-auth";
7
+ import type { CodexAppServerClient } from "@/lib/agents/runtime/codex-app-server-client";
8
+ import type {
9
+ OpenAIAccountInfo,
10
+ OpenAIRateLimitInfo,
11
+ } from "./openai-auth";
12
+
13
+ type LoginPhase =
14
+ | "idle"
15
+ | "pending"
16
+ | "connected"
17
+ | "cancelled"
18
+ | "failed";
19
+
20
+ export interface OpenAILoginState {
21
+ phase: LoginPhase;
22
+ loginId: string | null;
23
+ authUrl: string | null;
24
+ account: OpenAIAccountInfo | null;
25
+ rateLimits: OpenAIRateLimitInfo | null;
26
+ error: string | null;
27
+ startedAt: string | null;
28
+ updatedAt: string;
29
+ }
30
+
31
+ interface LoginAttempt {
32
+ client: CodexAppServerClient;
33
+ state: OpenAILoginState;
34
+ cancelRequested: boolean;
35
+ }
36
+
37
+ const idleState = (): OpenAILoginState => ({
38
+ phase: "idle",
39
+ loginId: null,
40
+ authUrl: null,
41
+ account: null,
42
+ rateLimits: null,
43
+ error: null,
44
+ startedAt: null,
45
+ updatedAt: new Date().toISOString(),
46
+ });
47
+
48
+ let activeAttempt: LoginAttempt | null = null;
49
+ let lastKnownState: OpenAILoginState = idleState();
50
+
51
+ function updateState(
52
+ next: Partial<OpenAILoginState> & Pick<OpenAILoginState, "phase">
53
+ ): OpenAILoginState {
54
+ lastKnownState = {
55
+ ...lastKnownState,
56
+ ...next,
57
+ updatedAt: new Date().toISOString(),
58
+ };
59
+ if (next.phase === "idle") {
60
+ lastKnownState = idleState();
61
+ }
62
+ return lastKnownState;
63
+ }
64
+
65
+ async function closeAttempt() {
66
+ const attempt = activeAttempt;
67
+ activeAttempt = null;
68
+ if (attempt) {
69
+ await attempt.client.close();
70
+ }
71
+ }
72
+
73
+ export function getOpenAILoginState(): OpenAILoginState {
74
+ return activeAttempt?.state ?? lastKnownState;
75
+ }
76
+
77
+ export async function startOpenAIChatGPTLogin(): Promise<OpenAILoginState> {
78
+ if (activeAttempt) {
79
+ return activeAttempt.state;
80
+ }
81
+
82
+ try {
83
+ const current = await readStagentCodexAuthState({ refreshToken: false });
84
+ if (current.connected) {
85
+ return updateState({
86
+ phase: "connected",
87
+ loginId: null,
88
+ authUrl: null,
89
+ account: current.account,
90
+ rateLimits: current.rateLimits,
91
+ error: null,
92
+ startedAt: null,
93
+ });
94
+ }
95
+ } catch {
96
+ await clearOpenAIOAuthStatus();
97
+ }
98
+
99
+ const client = await connectStagentCodexClient();
100
+ await initializeCodexClient(client);
101
+
102
+ const startedAt = new Date().toISOString();
103
+ const state = updateState({
104
+ phase: "pending",
105
+ loginId: null,
106
+ authUrl: null,
107
+ account: null,
108
+ rateLimits: null,
109
+ error: null,
110
+ startedAt,
111
+ });
112
+ const attempt: LoginAttempt = { client, state, cancelRequested: false };
113
+ activeAttempt = attempt;
114
+
115
+ client.onProcessError = (error) => {
116
+ void closeAttempt();
117
+ updateState({
118
+ phase: "failed",
119
+ error: error.message,
120
+ loginId: state.loginId,
121
+ authUrl: state.authUrl,
122
+ account: null,
123
+ rateLimits: null,
124
+ startedAt,
125
+ });
126
+ };
127
+
128
+ client.onNotification = (notification) => {
129
+ if (notification.method !== "account/login/completed") return;
130
+ const params = notification.params as
131
+ | { loginId?: string | null; success?: boolean; error?: string | null }
132
+ | undefined;
133
+
134
+ const completedLoginId = params?.loginId ?? state.loginId;
135
+ if (params?.success) {
136
+ void (async () => {
137
+ try {
138
+ const current = await readStagentCodexAuthState({ refreshToken: true });
139
+ updateState({
140
+ phase: "connected",
141
+ loginId: completedLoginId ?? null,
142
+ authUrl: state.authUrl,
143
+ account: current.account,
144
+ rateLimits: current.rateLimits,
145
+ error: null,
146
+ startedAt,
147
+ });
148
+ } catch (error) {
149
+ updateState({
150
+ phase: "failed",
151
+ loginId: completedLoginId ?? null,
152
+ authUrl: state.authUrl,
153
+ account: null,
154
+ rateLimits: null,
155
+ error: error instanceof Error ? error.message : String(error),
156
+ startedAt,
157
+ });
158
+ } finally {
159
+ await closeAttempt();
160
+ }
161
+ })();
162
+ return;
163
+ }
164
+
165
+ void (async () => {
166
+ const cancelled =
167
+ attempt.cancelRequested ||
168
+ params?.error?.toLowerCase().includes("cancel") ||
169
+ params?.error === "Login was not completed";
170
+ updateState({
171
+ phase: cancelled ? "cancelled" : "failed",
172
+ loginId: completedLoginId ?? null,
173
+ authUrl: state.authUrl,
174
+ account: null,
175
+ rateLimits: null,
176
+ error: cancelled ? null : params?.error ?? "ChatGPT login failed",
177
+ startedAt,
178
+ });
179
+ await clearOpenAIOAuthStatus();
180
+ await closeAttempt();
181
+ })();
182
+ };
183
+
184
+ try {
185
+ const result = (await client.request("account/login/start", {
186
+ type: "chatgpt",
187
+ })) as {
188
+ type: "chatgpt";
189
+ loginId: string;
190
+ authUrl: string;
191
+ };
192
+
193
+ const next = updateState({
194
+ phase: "pending",
195
+ loginId: result.loginId,
196
+ authUrl: result.authUrl,
197
+ account: null,
198
+ rateLimits: null,
199
+ error: null,
200
+ startedAt,
201
+ });
202
+ if (activeAttempt) {
203
+ activeAttempt.state = next;
204
+ }
205
+ return next;
206
+ } catch (error) {
207
+ await clearOpenAIOAuthStatus();
208
+ await closeAttempt();
209
+ return updateState({
210
+ phase: "failed",
211
+ loginId: null,
212
+ authUrl: null,
213
+ account: null,
214
+ rateLimits: null,
215
+ error: error instanceof Error ? error.message : String(error),
216
+ startedAt,
217
+ });
218
+ }
219
+ }
220
+
221
+ export async function cancelOpenAIChatGPTLogin(): Promise<OpenAILoginState> {
222
+ const attempt = activeAttempt;
223
+ if (!attempt?.state.loginId) {
224
+ return getOpenAILoginState();
225
+ }
226
+
227
+ attempt.cancelRequested = true;
228
+
229
+ try {
230
+ await attempt.client.request("account/login/cancel", {
231
+ loginId: attempt.state.loginId,
232
+ });
233
+ const next = updateState({
234
+ phase: "cancelled",
235
+ loginId: attempt.state.loginId,
236
+ authUrl: attempt.state.authUrl,
237
+ account: null,
238
+ rateLimits: null,
239
+ error: null,
240
+ startedAt: attempt.state.startedAt,
241
+ });
242
+ attempt.state = next;
243
+ await clearOpenAIOAuthStatus();
244
+ await closeAttempt();
245
+ return next;
246
+ } catch (error) {
247
+ updateState({
248
+ phase: "failed",
249
+ loginId: attempt.state.loginId,
250
+ authUrl: attempt.state.authUrl,
251
+ account: null,
252
+ rateLimits: null,
253
+ error: error instanceof Error ? error.message : String(error),
254
+ startedAt: attempt.state.startedAt,
255
+ });
256
+ await closeAttempt();
257
+ }
258
+
259
+ return getOpenAILoginState();
260
+ }
@@ -54,10 +54,20 @@ export async function getRuntimeSetupStates(): Promise<
54
54
  runtimeId: "openai-codex-app-server",
55
55
  label: openAIRuntime.label,
56
56
  providerId: openAIRuntime.providerId,
57
- configured: openAIAuth.hasKey,
58
- authMethod: openAIAuth.hasKey ? "api_key" : "none",
59
- apiKeySource: openAIAuth.apiKeySource,
60
- billingMode: "usage",
57
+ configured:
58
+ openAIAuth.method === "oauth" ? openAIAuth.oauthConnected : openAIAuth.hasKey,
59
+ authMethod:
60
+ openAIAuth.method === "oauth"
61
+ ? "oauth"
62
+ : openAIAuth.hasKey
63
+ ? "api_key"
64
+ : "none",
65
+ apiKeySource:
66
+ openAIAuth.method === "oauth" ? "oauth" : openAIAuth.apiKeySource,
67
+ billingMode:
68
+ openAIAuth.method === "oauth" && openAIAuth.oauthConnected
69
+ ? "subscription"
70
+ : "usage",
61
71
  },
62
72
  "anthropic-direct": {
63
73
  runtimeId: "anthropic-direct",
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildEnrichmentPlan,
4
+ buildTargetContract,
5
+ normalizeEnrichmentOutput,
6
+ } from "../enrichment-planner";
7
+ import type { ColumnDef } from "../types";
8
+
9
+ const baseColumn: ColumnDef = {
10
+ name: "linkedin_url",
11
+ displayName: "LinkedIn URL",
12
+ dataType: "url",
13
+ position: 0,
14
+ };
15
+
16
+ describe("buildTargetContract", () => {
17
+ it("preserves select options for categorical columns", () => {
18
+ const contract = buildTargetContract({
19
+ name: "status",
20
+ displayName: "Status",
21
+ dataType: "select",
22
+ position: 1,
23
+ config: { options: ["Lead", "Qualified", "Closed Won"] },
24
+ });
25
+
26
+ expect(contract.allowedOptions).toEqual(["Lead", "Qualified", "Closed Won"]);
27
+ });
28
+ });
29
+
30
+ describe("buildEnrichmentPlan", () => {
31
+ it("chooses single-pass-classify for boolean columns", () => {
32
+ const plan = buildEnrichmentPlan({
33
+ targetColumn: {
34
+ name: "qualified",
35
+ displayName: "Qualified",
36
+ dataType: "boolean",
37
+ position: 0,
38
+ },
39
+ sampleRows: [{ id: "row_1", data: { company: "Acme" } }],
40
+ eligibleRowCount: 1,
41
+ promptMode: "auto",
42
+ });
43
+
44
+ expect(plan.strategy).toBe("single-pass-classify");
45
+ expect(plan.steps).toHaveLength(1);
46
+ });
47
+
48
+ it("chooses research-and-synthesize when text guidance asks for synthesis", () => {
49
+ const plan = buildEnrichmentPlan({
50
+ targetColumn: {
51
+ name: "summary",
52
+ displayName: "Summary",
53
+ dataType: "text",
54
+ position: 0,
55
+ },
56
+ sampleRows: [{ id: "row_1", data: { company: "Acme" } }],
57
+ eligibleRowCount: 1,
58
+ promptMode: "auto",
59
+ prompt: "Research the company and synthesize a short account summary.",
60
+ });
61
+
62
+ expect(plan.strategy).toBe("research-and-synthesize");
63
+ expect(plan.steps).toHaveLength(2);
64
+ expect(plan.steps[1].prompt).toContain("{{previous}}");
65
+ });
66
+
67
+ it("keeps custom mode single-step and wraps the typed contract", () => {
68
+ const plan = buildEnrichmentPlan({
69
+ targetColumn: baseColumn,
70
+ sampleRows: [{ id: "row_1", data: { company: "Acme" } }],
71
+ eligibleRowCount: 1,
72
+ promptMode: "custom",
73
+ prompt: "Find the profile URL.",
74
+ });
75
+
76
+ expect(plan.promptMode).toBe("custom");
77
+ expect(plan.steps).toHaveLength(1);
78
+ expect(plan.steps[0].prompt).toContain("RESPONSE FORMAT");
79
+ });
80
+ });
81
+
82
+ describe("normalizeEnrichmentOutput", () => {
83
+ it("canonicalizes select output to the configured option casing", () => {
84
+ const result = normalizeEnrichmentOutput("qualified", {
85
+ columnName: "status",
86
+ columnLabel: "Status",
87
+ dataType: "select",
88
+ allowedOptions: ["Lead", "Qualified", "Closed Won"],
89
+ });
90
+
91
+ expect(result).toEqual({ kind: "valid", value: "Qualified" });
92
+ });
93
+
94
+ it("parses booleans and numbers into typed values", () => {
95
+ expect(
96
+ normalizeEnrichmentOutput("true", {
97
+ columnName: "qualified",
98
+ columnLabel: "Qualified",
99
+ dataType: "boolean",
100
+ })
101
+ ).toEqual({ kind: "valid", value: true });
102
+
103
+ expect(
104
+ normalizeEnrichmentOutput("42", {
105
+ columnName: "score",
106
+ columnLabel: "Score",
107
+ dataType: "number",
108
+ })
109
+ ).toEqual({ kind: "valid", value: 42 });
110
+ });
111
+
112
+ it("rejects invalid urls", () => {
113
+ expect(
114
+ normalizeEnrichmentOutput("not-a-url", {
115
+ columnName: "linkedin_url",
116
+ columnLabel: "LinkedIn URL",
117
+ dataType: "url",
118
+ })
119
+ ).toEqual({
120
+ kind: "invalid",
121
+ reason: "Expected a valid URL or NOT_FOUND",
122
+ });
123
+ });
124
+ });