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,153 @@
1
+ /**
2
+ * Upgrade Poller — background service that polls origin/main for new commits.
3
+ *
4
+ * Uses `git fetch` locally (not the GitHub REST API) to avoid rate limits
5
+ * and auth token management. Runs alongside the scheduler via instrumentation.ts.
6
+ *
7
+ * Skipped entirely if dev-mode is active or .git is absent — mirrors the
8
+ * instance bootstrap gating.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "fs";
12
+ import { join } from "path";
13
+ import { isDevMode, hasGitDir } from "./detect";
14
+ import { createGitOps } from "./git-ops";
15
+ import { getUpgradeState, setUpgradeState } from "./settings";
16
+
17
+ const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
18
+ const LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
19
+ const LOCK_FILENAME = ".stagent-upgrade-check.lock";
20
+
21
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
22
+ let ticking = false;
23
+
24
+ /** Poll once immediately, then on a 1-hour interval. */
25
+ export function startUpgradePoller(): void {
26
+ if (intervalHandle !== null) return;
27
+ if (isDevMode() || !hasGitDir()) {
28
+ console.log(`[upgrade-poller] skipped (dev mode or no .git)`);
29
+ return;
30
+ }
31
+
32
+ // Kick off an initial poll on boot, then schedule hourly.
33
+ tick().catch((err) => console.error("[upgrade-poller] initial tick error:", err));
34
+ intervalHandle = setInterval(() => {
35
+ tick().catch((err) => console.error("[upgrade-poller] tick error:", err));
36
+ }, POLL_INTERVAL_MS);
37
+ console.log(`[upgrade-poller] started — polling every ${POLL_INTERVAL_MS / 1000 / 60}m`);
38
+ }
39
+
40
+ export function stopUpgradePoller(): void {
41
+ if (intervalHandle !== null) {
42
+ clearInterval(intervalHandle);
43
+ intervalHandle = null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Exposed for testing and for the manual `POST /api/instance/upgrade/check`
49
+ * force-check endpoint. Returns the new state, or null if skipped (lock held,
50
+ * or dev mode).
51
+ */
52
+ export async function tick(cwd: string = process.cwd()): Promise<UpgradeTickResult> {
53
+ if (ticking) return { skipped: "in_progress" };
54
+ if (isDevMode(cwd) || !hasGitDir(cwd)) return { skipped: "dev_mode_or_no_git" };
55
+
56
+ ticking = true;
57
+ try {
58
+ // Advisory lock: cross-process safety (subprocess + dev server + manual check)
59
+ const lockPath = join(cwd, ".git", LOCK_FILENAME);
60
+ if (!acquireLock(lockPath)) {
61
+ return { skipped: "lock_held" };
62
+ }
63
+
64
+ try {
65
+ const git = createGitOps(cwd);
66
+ const current = getUpgradeState();
67
+
68
+ try {
69
+ git.fetchOrigin();
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ await setUpgradeState({
73
+ ...current,
74
+ lastPolledAt: Math.floor(Date.now() / 1000),
75
+ pollFailureCount: current.pollFailureCount + 1,
76
+ lastPollError: message,
77
+ });
78
+ return { skipped: "fetch_failed", error: message };
79
+ }
80
+
81
+ const upstreamSha = git.revParse("origin/main");
82
+ const localMainSha = git.revParse("main");
83
+ const commitsBehind =
84
+ upstreamSha && localMainSha ? git.countCommitsAhead("main", "origin/main") : 0;
85
+
86
+ const newState = {
87
+ ...current,
88
+ lastPolledAt: Math.floor(Date.now() / 1000),
89
+ lastUpstreamSha: upstreamSha,
90
+ localMainSha: localMainSha,
91
+ upgradeAvailable: commitsBehind > 0,
92
+ commitsBehind,
93
+ pollFailureCount: 0,
94
+ lastPollError: null,
95
+ };
96
+ await setUpgradeState(newState);
97
+ return { updated: newState };
98
+ } finally {
99
+ releaseLock(lockPath);
100
+ }
101
+ } finally {
102
+ ticking = false;
103
+ }
104
+ }
105
+
106
+ export interface UpgradeTickResult {
107
+ updated?: ReturnType<typeof getUpgradeState>;
108
+ skipped?: "in_progress" | "dev_mode_or_no_git" | "lock_held" | "fetch_failed";
109
+ error?: string;
110
+ }
111
+
112
+ function acquireLock(lockPath: string): boolean {
113
+ // Ensure .git/ exists (it does in normal repos; belt-and-suspenders)
114
+ try {
115
+ mkdirSync(join(lockPath, ".."), { recursive: true });
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+
120
+ if (existsSync(lockPath)) {
121
+ // Stale lock check: if older than TTL, break it
122
+ try {
123
+ const age = Date.now() - statSync(lockPath).mtimeMs;
124
+ if (age > LOCK_TTL_MS) {
125
+ unlinkSync(lockPath);
126
+ } else {
127
+ return false;
128
+ }
129
+ } catch {
130
+ // Stat failed; treat as stale and try to remove
131
+ try {
132
+ unlinkSync(lockPath);
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+ }
138
+
139
+ try {
140
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function releaseLock(lockPath: string): void {
148
+ try {
149
+ unlinkSync(lockPath);
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ filterDefaultVisibleNotifications,
5
+ isResolvedLearningNotification,
6
+ } from "@/lib/notifications/visibility";
7
+
8
+ describe("notification visibility", () => {
9
+ it("treats responded learning notifications as resolved", () => {
10
+ expect(
11
+ isResolvedLearningNotification({
12
+ type: "context_proposal",
13
+ response: JSON.stringify({ action: "approved" }),
14
+ respondedAt: "2026-04-10T00:00:00.000Z",
15
+ })
16
+ ).toBe(true);
17
+
18
+ expect(
19
+ isResolvedLearningNotification({
20
+ type: "context_proposal_batch",
21
+ response: null,
22
+ respondedAt: null,
23
+ })
24
+ ).toBe(false);
25
+ });
26
+
27
+ it("filters resolved learning notifications but keeps other notification types", () => {
28
+ const visible = filterDefaultVisibleNotifications([
29
+ {
30
+ id: "n1",
31
+ type: "context_proposal",
32
+ response: JSON.stringify({ action: "approved" }),
33
+ respondedAt: "2026-04-10T00:00:00.000Z",
34
+ },
35
+ {
36
+ id: "n2",
37
+ type: "context_proposal_batch",
38
+ response: null,
39
+ respondedAt: null,
40
+ },
41
+ {
42
+ id: "n3",
43
+ type: "permission_required",
44
+ response: JSON.stringify({ behavior: "allow" }),
45
+ respondedAt: "2026-04-10T00:00:00.000Z",
46
+ },
47
+ ]);
48
+
49
+ expect(visible.map((item) => item.id)).toEqual(["n2", "n3"]);
50
+ });
51
+ });
@@ -0,0 +1,33 @@
1
+ import { sql } from "drizzle-orm";
2
+
3
+ import { notifications } from "@/lib/db/schema";
4
+
5
+ export interface NotificationVisibilityRecord {
6
+ type: string;
7
+ response: string | null;
8
+ respondedAt: string | Date | null;
9
+ }
10
+
11
+ export function isLearningNotificationType(type: string): boolean {
12
+ return type === "context_proposal" || type === "context_proposal_batch";
13
+ }
14
+
15
+ export function isResolvedLearningNotification(
16
+ notification: NotificationVisibilityRecord
17
+ ): boolean {
18
+ if (!isLearningNotificationType(notification.type)) {
19
+ return false;
20
+ }
21
+
22
+ return notification.response !== null || notification.respondedAt !== null;
23
+ }
24
+
25
+ export function filterDefaultVisibleNotifications<T extends NotificationVisibilityRecord>(
26
+ items: T[]
27
+ ): T[] {
28
+ return items.filter((item) => !isResolvedLearningNotification(item));
29
+ }
30
+
31
+ export function buildDefaultNotificationVisibilityCondition() {
32
+ return sql`(${notifications.type} NOT IN ('context_proposal', 'context_proposal_batch') OR (${notifications.response} IS NULL AND ${notifications.respondedAt} IS NULL))`;
33
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { schedules, projects } from "@/lib/db/schema";
4
+ import { randomUUID } from "crypto";
5
+ import { checkCollision } from "../collision-check";
6
+
7
+ function seedSchedule(opts: {
8
+ cron: string;
9
+ avgTurns: number;
10
+ projectId: string;
11
+ status?: "active" | "paused";
12
+ }): string {
13
+ const id = randomUUID();
14
+ const now = new Date();
15
+ db.insert(schedules)
16
+ .values({
17
+ id,
18
+ projectId: opts.projectId,
19
+ name: `s-${id.slice(0, 4)}`,
20
+ prompt: "test",
21
+ cronExpression: opts.cron,
22
+ status: opts.status ?? "active",
23
+ type: "scheduled",
24
+ firingCount: 0,
25
+ suppressionCount: 0,
26
+ heartbeatSpentToday: 0,
27
+ failureStreak: 0,
28
+ turnBudgetBreachStreak: 0,
29
+ avgTurnsPerFiring: opts.avgTurns,
30
+ createdAt: now,
31
+ updatedAt: now,
32
+ })
33
+ .run();
34
+ return id;
35
+ }
36
+
37
+ function seedProject(): string {
38
+ const id = randomUUID();
39
+ const now = new Date();
40
+ db.insert(projects)
41
+ .values({ id, name: "p", status: "active", createdAt: now, updatedAt: now })
42
+ .run();
43
+ return id;
44
+ }
45
+
46
+ describe("checkCollision", () => {
47
+ beforeEach(() => {
48
+ db.delete(schedules).run();
49
+ db.delete(projects).run();
50
+ });
51
+
52
+ it("returns no warnings when no overlap exists", () => {
53
+ const pid = seedProject();
54
+ seedSchedule({ cron: "0 3 * * *", avgTurns: 500, projectId: pid });
55
+ expect(checkCollision("0 15 * * *", 500, pid, null)).toEqual([]);
56
+ });
57
+
58
+ it("detects overlap when two heavy schedules share a 5-min bucket", () => {
59
+ const pid = seedProject();
60
+ seedSchedule({ cron: "2 * * * *", avgTurns: 2000, projectId: pid });
61
+ const warnings = checkCollision("0 * * * *", 2000, pid, null);
62
+ expect(warnings.length).toBe(1);
63
+ expect(warnings[0].type).toBe("cron_collision");
64
+ expect(warnings[0].estimatedConcurrentSteps).toBeGreaterThanOrEqual(4000);
65
+ });
66
+
67
+ it("ignores paused schedules", () => {
68
+ const pid = seedProject();
69
+ seedSchedule({
70
+ cron: "2 * * * *",
71
+ avgTurns: 2000,
72
+ projectId: pid,
73
+ status: "paused",
74
+ });
75
+ expect(checkCollision("0 * * * *", 2000, pid, null)).toEqual([]);
76
+ });
77
+
78
+ it("excludes the excludeScheduleId (for PUT updates)", () => {
79
+ const pid = seedProject();
80
+ const existing = seedSchedule({
81
+ cron: "0 * * * *",
82
+ avgTurns: 3000,
83
+ projectId: pid,
84
+ });
85
+ expect(checkCollision("0 * * * *", 3000, pid, existing)).toEqual([]);
86
+ });
87
+
88
+ it("does not warn when combined steps are below the threshold", () => {
89
+ const pid = seedProject();
90
+ seedSchedule({ cron: "2 * * * *", avgTurns: 500, projectId: pid });
91
+ expect(checkCollision("0 * * * *", 500, pid, null)).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { settings } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import {
6
+ getScheduleMaxConcurrent,
7
+ getScheduleMaxRunDurationSec,
8
+ getScheduleChatPressureDelaySec,
9
+ } from "../config";
10
+
11
+ describe("schedule config", () => {
12
+ beforeEach(() => {
13
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
14
+ db.delete(settings).where(eq(settings.key, "schedule.maxRunDurationSec")).run();
15
+ db.delete(settings).where(eq(settings.key, "schedule.chatPressureDelaySec")).run();
16
+ });
17
+
18
+ it("returns default max concurrent of 2 when setting is absent", () => {
19
+ expect(getScheduleMaxConcurrent()).toBe(2);
20
+ });
21
+
22
+ it("reads max concurrent from settings when set", () => {
23
+ db.insert(settings)
24
+ .values({
25
+ key: "schedule.maxConcurrent",
26
+ value: "3",
27
+ updatedAt: new Date(),
28
+ })
29
+ .run();
30
+ expect(getScheduleMaxConcurrent()).toBe(3);
31
+ });
32
+
33
+ it("reads max concurrent from SCHEDULE_MAX_CONCURRENT env var", () => {
34
+ const original = process.env.SCHEDULE_MAX_CONCURRENT;
35
+ process.env.SCHEDULE_MAX_CONCURRENT = "5";
36
+ try {
37
+ expect(getScheduleMaxConcurrent()).toBe(5);
38
+ } finally {
39
+ if (original === undefined) delete process.env.SCHEDULE_MAX_CONCURRENT;
40
+ else process.env.SCHEDULE_MAX_CONCURRENT = original;
41
+ }
42
+ });
43
+
44
+ it("falls back to default when env var is NaN", () => {
45
+ const original = process.env.SCHEDULE_MAX_CONCURRENT;
46
+ process.env.SCHEDULE_MAX_CONCURRENT = "abc";
47
+ try {
48
+ expect(getScheduleMaxConcurrent()).toBe(2);
49
+ } finally {
50
+ if (original === undefined) delete process.env.SCHEDULE_MAX_CONCURRENT;
51
+ else process.env.SCHEDULE_MAX_CONCURRENT = original;
52
+ }
53
+ });
54
+
55
+ it("returns default max run duration of 1200s", () => {
56
+ expect(getScheduleMaxRunDurationSec()).toBe(1200);
57
+ });
58
+
59
+ it("returns default chat pressure delay of 30s", () => {
60
+ expect(getScheduleChatPressureDelaySec()).toBe(30);
61
+ });
62
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import {
4
+ tasks,
5
+ schedules,
6
+ projects,
7
+ scheduleFiringMetrics,
8
+ agentLogs,
9
+ } from "@/lib/db/schema";
10
+ import { eq } from "drizzle-orm";
11
+ import { randomUUID } from "crypto";
12
+ import { recordFiringMetrics } from "../scheduler";
13
+
14
+ describe("schedule_firing_metrics insertion", () => {
15
+ beforeEach(() => {
16
+ db.delete(scheduleFiringMetrics).run();
17
+ db.delete(agentLogs).run();
18
+ db.delete(tasks).run();
19
+ db.delete(schedules).run();
20
+ db.delete(projects).run();
21
+ });
22
+
23
+ it("inserts a row for every firing with slot_wait_ms and duration_ms", async () => {
24
+ const pid = randomUUID();
25
+ const sid = randomUUID();
26
+ const tid = randomUUID();
27
+ const firedAt = new Date(Date.now() - 5000);
28
+ const slotClaimedAt = new Date(Date.now() - 4000);
29
+ const completedAt = new Date(Date.now() - 100);
30
+
31
+ db.insert(projects)
32
+ .values({
33
+ id: pid,
34
+ name: "p",
35
+ status: "active",
36
+ createdAt: firedAt,
37
+ updatedAt: firedAt,
38
+ })
39
+ .run();
40
+ db.insert(schedules)
41
+ .values({
42
+ id: sid,
43
+ projectId: pid,
44
+ name: "test",
45
+ prompt: "x",
46
+ cronExpression: "* * * * *",
47
+ status: "active",
48
+ type: "scheduled",
49
+ firingCount: 1,
50
+ suppressionCount: 0,
51
+ heartbeatSpentToday: 0,
52
+ failureStreak: 0,
53
+ turnBudgetBreachStreak: 0,
54
+ maxTurns: 50,
55
+ createdAt: firedAt,
56
+ updatedAt: firedAt,
57
+ })
58
+ .run();
59
+ db.insert(tasks)
60
+ .values({
61
+ id: tid,
62
+ scheduleId: sid,
63
+ title: "firing",
64
+ status: "completed",
65
+ priority: 2,
66
+ sourceType: "scheduled",
67
+ resumeCount: 0,
68
+ slotClaimedAt,
69
+ createdAt: firedAt,
70
+ updatedAt: completedAt,
71
+ })
72
+ .run();
73
+ for (let i = 0; i < 7; i++) {
74
+ db.insert(agentLogs)
75
+ .values({
76
+ id: randomUUID(),
77
+ taskId: tid,
78
+ agentType: "test",
79
+ event: "assistant_message",
80
+ timestamp: new Date(),
81
+ })
82
+ .run();
83
+ }
84
+
85
+ await recordFiringMetrics(sid, tid);
86
+
87
+ const rows = db
88
+ .select()
89
+ .from(scheduleFiringMetrics)
90
+ .where(eq(scheduleFiringMetrics.scheduleId, sid))
91
+ .all();
92
+
93
+ expect(rows.length).toBe(1);
94
+ expect(rows[0].turnCount).toBe(7);
95
+ expect(rows[0].maxTurnsAtFiring).toBe(50);
96
+ expect(rows[0].slotWaitMs).toBeGreaterThan(0);
97
+ expect(rows[0].durationMs).toBeGreaterThan(0);
98
+ });
99
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import {
4
+ tasks,
5
+ schedules,
6
+ projects,
7
+ settings,
8
+ scheduleFiringMetrics,
9
+ agentLogs,
10
+ } from "@/lib/db/schema";
11
+ import { eq } from "drizzle-orm";
12
+ import { randomUUID } from "crypto";
13
+ import { tickScheduler } from "../scheduler";
14
+ import { countRunningScheduledSlots } from "../slot-claim";
15
+
16
+ vi.mock("@/lib/agents/runtime", () => ({
17
+ executeTaskWithRuntime: vi.fn(async () => {
18
+ // Simulate a short-running task
19
+ await new Promise((r) => setTimeout(r, 20));
20
+ }),
21
+ }));
22
+
23
+ describe("schedule orchestration end-to-end", () => {
24
+ beforeEach(() => {
25
+ db.delete(scheduleFiringMetrics).run();
26
+ db.delete(agentLogs).run();
27
+ db.delete(tasks).run();
28
+ db.delete(schedules).run();
29
+ db.delete(projects).run();
30
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
31
+ db.insert(settings)
32
+ .values({ key: "schedule.maxConcurrent", value: "2", updatedAt: new Date() })
33
+ .run();
34
+ });
35
+
36
+ it("5 schedules firing at once → exactly 2 run, 3 queue", async () => {
37
+ const pid = randomUUID();
38
+ const now = new Date();
39
+ db.insert(projects)
40
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
41
+ .run();
42
+
43
+ const past = new Date(now.getTime() - 10_000);
44
+ for (let i = 0; i < 5; i++) {
45
+ db.insert(schedules)
46
+ .values({
47
+ id: randomUUID(),
48
+ projectId: pid,
49
+ name: `sched-${i}`,
50
+ prompt: "test",
51
+ cronExpression: "* * * * *",
52
+ status: "active",
53
+ type: "scheduled",
54
+ firingCount: 0,
55
+ suppressionCount: 0,
56
+ heartbeatSpentToday: 0,
57
+ failureStreak: 0,
58
+ turnBudgetBreachStreak: 0,
59
+ nextFireAt: past,
60
+ createdAt: now,
61
+ updatedAt: now,
62
+ })
63
+ .run();
64
+ }
65
+
66
+ await tickScheduler();
67
+
68
+ expect(countRunningScheduledSlots()).toBe(2);
69
+ const running = db
70
+ .select()
71
+ .from(tasks)
72
+ .where(eq(tasks.status, "running"))
73
+ .all();
74
+ expect(running.length).toBe(2);
75
+ const queued = db
76
+ .select()
77
+ .from(tasks)
78
+ .where(eq(tasks.status, "queued"))
79
+ .all();
80
+ expect(queued.length).toBe(3);
81
+ });
82
+ });