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.
- package/README.md +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- 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
|
-
|
|
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
|
|
137
|
-
*
|
|
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({
|
|
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
|
|
166
|
-
|
|
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:
|
|
174
|
-
|
|
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 (
|
|
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
|
|
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
|
+
});
|