stagent 0.1.11 → 0.1.12
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 +35 -4
- package/package.json +3 -2
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -20
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +6 -3
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/learned-context.ts +27 -15
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +19 -0
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +2 -2
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +1 -1
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -95
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/settings.ts +3 -9
- package/src/lib/workflows/engine.ts +18 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import {
|
|
3
|
+
tasks,
|
|
4
|
+
workflows,
|
|
5
|
+
documents,
|
|
6
|
+
schedules,
|
|
7
|
+
usageLedger,
|
|
8
|
+
learnedContext,
|
|
9
|
+
settings,
|
|
10
|
+
} from "@/lib/db/schema";
|
|
11
|
+
import { count, like, sql } from "drizzle-orm";
|
|
12
|
+
import type { AdoptionEntry } from "./types";
|
|
13
|
+
|
|
14
|
+
function toDepth(n: number): AdoptionEntry["depth"] {
|
|
15
|
+
if (n === 0) return "none";
|
|
16
|
+
if (n <= 3) return "light";
|
|
17
|
+
return "deep";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function entry(n: number): AdoptionEntry {
|
|
21
|
+
return { adopted: n > 0, depth: toDepth(n) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Map each manifest section slug to DB evidence of adoption */
|
|
25
|
+
export async function getAdoptionMap(): Promise<Map<string, AdoptionEntry>> {
|
|
26
|
+
const [
|
|
27
|
+
taskCount,
|
|
28
|
+
workflowCount,
|
|
29
|
+
documentCount,
|
|
30
|
+
scheduleCount,
|
|
31
|
+
profileUsed,
|
|
32
|
+
usageCount,
|
|
33
|
+
learnedCount,
|
|
34
|
+
permissionCount,
|
|
35
|
+
providerCount,
|
|
36
|
+
] = await Promise.all([
|
|
37
|
+
db
|
|
38
|
+
.select({ n: count() })
|
|
39
|
+
.from(tasks)
|
|
40
|
+
.then((r) => r[0]?.n ?? 0),
|
|
41
|
+
db
|
|
42
|
+
.select({ n: count() })
|
|
43
|
+
.from(workflows)
|
|
44
|
+
.then((r) => r[0]?.n ?? 0),
|
|
45
|
+
db
|
|
46
|
+
.select({ n: count() })
|
|
47
|
+
.from(documents)
|
|
48
|
+
.then((r) => r[0]?.n ?? 0),
|
|
49
|
+
db
|
|
50
|
+
.select({ n: count() })
|
|
51
|
+
.from(schedules)
|
|
52
|
+
.then((r) => r[0]?.n ?? 0),
|
|
53
|
+
db
|
|
54
|
+
.select({ n: count() })
|
|
55
|
+
.from(tasks)
|
|
56
|
+
.where(sql`${tasks.agentProfile} IS NOT NULL`)
|
|
57
|
+
.then((r) => r[0]?.n ?? 0),
|
|
58
|
+
db
|
|
59
|
+
.select({ n: count() })
|
|
60
|
+
.from(usageLedger)
|
|
61
|
+
.then((r) => r[0]?.n ?? 0),
|
|
62
|
+
db
|
|
63
|
+
.select({ n: count() })
|
|
64
|
+
.from(learnedContext)
|
|
65
|
+
.then((r) => r[0]?.n ?? 0),
|
|
66
|
+
db
|
|
67
|
+
.select({ n: count() })
|
|
68
|
+
.from(settings)
|
|
69
|
+
.where(like(settings.key, "permission:%"))
|
|
70
|
+
.then((r) => r[0]?.n ?? 0),
|
|
71
|
+
db
|
|
72
|
+
.select({ n: sql<number>`COUNT(DISTINCT ${usageLedger.providerId})` })
|
|
73
|
+
.from(usageLedger)
|
|
74
|
+
.then((r) => Number(r[0]?.n ?? 0)),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const map = new Map<string, AdoptionEntry>();
|
|
78
|
+
|
|
79
|
+
// Always adopted
|
|
80
|
+
map.set("home-workspace", { adopted: true, depth: "deep" });
|
|
81
|
+
map.set("settings", { adopted: true, depth: "deep" });
|
|
82
|
+
|
|
83
|
+
// Feature sections
|
|
84
|
+
map.set("dashboard-kanban", entry(taskCount));
|
|
85
|
+
map.set("inbox-notifications", entry(taskCount)); // notifications come with tasks
|
|
86
|
+
map.set("monitoring", entry(taskCount));
|
|
87
|
+
map.set("projects", entry(taskCount)); // projects enable tasks
|
|
88
|
+
map.set("workflows", entry(workflowCount));
|
|
89
|
+
map.set("documents", entry(documentCount));
|
|
90
|
+
map.set("profiles", entry(profileUsed));
|
|
91
|
+
map.set("schedules", entry(scheduleCount));
|
|
92
|
+
map.set("cost-usage", entry(usageCount));
|
|
93
|
+
|
|
94
|
+
// Cross-cutting
|
|
95
|
+
map.set(
|
|
96
|
+
"provider-runtimes",
|
|
97
|
+
providerCount > 1
|
|
98
|
+
? { adopted: true, depth: "deep" }
|
|
99
|
+
: entry(usageCount > 0 ? 1 : 0)
|
|
100
|
+
);
|
|
101
|
+
map.set("agent-intelligence", entry(learnedCount));
|
|
102
|
+
map.set("tool-permissions", entry(permissionCount));
|
|
103
|
+
|
|
104
|
+
return map;
|
|
105
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AdoptionEntry, DocJourney, JourneyCompletion } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Compute journey completion from adoption data */
|
|
4
|
+
export function getJourneyCompletions(
|
|
5
|
+
journeys: DocJourney[],
|
|
6
|
+
adoptionMap: Map<string, AdoptionEntry>
|
|
7
|
+
): Map<string, JourneyCompletion> {
|
|
8
|
+
const completions = new Map<string, JourneyCompletion>();
|
|
9
|
+
|
|
10
|
+
for (const journey of journeys) {
|
|
11
|
+
const total = journey.sections.length;
|
|
12
|
+
const completed = journey.sections.filter(
|
|
13
|
+
(slug) => adoptionMap.get(slug)?.adopted === true
|
|
14
|
+
).length;
|
|
15
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
16
|
+
|
|
17
|
+
completions.set(journey.slug, { completed, total, percentage });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return completions;
|
|
21
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "fs";
|
|
2
|
+
import { join, basename } from "path";
|
|
3
|
+
import type { DocManifest, ParsedDoc } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Resolve the docs directory relative to project root */
|
|
6
|
+
function docsDir(): string {
|
|
7
|
+
return join(process.cwd(), "docs");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Read and parse docs/manifest.json */
|
|
11
|
+
export function getManifest(): DocManifest {
|
|
12
|
+
const raw = readFileSync(join(docsDir(), "manifest.json"), "utf-8");
|
|
13
|
+
return JSON.parse(raw) as DocManifest;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Parse a markdown file into frontmatter + body */
|
|
17
|
+
function parseMarkdown(content: string, slug: string): ParsedDoc {
|
|
18
|
+
const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
19
|
+
const match = content.match(fmRegex);
|
|
20
|
+
|
|
21
|
+
if (!match) {
|
|
22
|
+
return { frontmatter: {}, body: content, slug };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const frontmatter: Record<string, unknown> = {};
|
|
26
|
+
const fmLines = match[1].split("\n");
|
|
27
|
+
for (const line of fmLines) {
|
|
28
|
+
const colonIdx = line.indexOf(":");
|
|
29
|
+
if (colonIdx === -1) continue;
|
|
30
|
+
const key = line.slice(0, colonIdx).trim();
|
|
31
|
+
let value: unknown = line.slice(colonIdx + 1).trim();
|
|
32
|
+
|
|
33
|
+
// Strip surrounding quotes
|
|
34
|
+
if (
|
|
35
|
+
typeof value === "string" &&
|
|
36
|
+
value.startsWith('"') &&
|
|
37
|
+
value.endsWith('"')
|
|
38
|
+
) {
|
|
39
|
+
value = value.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse arrays: ["a", "b"]
|
|
43
|
+
if (typeof value === "string" && value.startsWith("[")) {
|
|
44
|
+
try {
|
|
45
|
+
value = JSON.parse(value);
|
|
46
|
+
} catch {
|
|
47
|
+
// keep as string
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
frontmatter[key] = value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { frontmatter, body: match[2], slug };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read a single doc by slug (searches features/ then journeys/ then root) */
|
|
58
|
+
export function getDocBySlug(slug: string): ParsedDoc | null {
|
|
59
|
+
const dirs = ["features", "journeys", ""];
|
|
60
|
+
for (const dir of dirs) {
|
|
61
|
+
const filePath = join(docsDir(), dir, `${slug}.md`);
|
|
62
|
+
if (existsSync(filePath)) {
|
|
63
|
+
const content = readFileSync(filePath, "utf-8");
|
|
64
|
+
return parseMarkdown(content, slug);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read all docs from features/ and journeys/ */
|
|
71
|
+
export function getAllDocs(): ParsedDoc[] {
|
|
72
|
+
const docs: ParsedDoc[] = [];
|
|
73
|
+
const subDirs = ["features", "journeys"];
|
|
74
|
+
|
|
75
|
+
for (const dir of subDirs) {
|
|
76
|
+
const dirPath = join(docsDir(), dir);
|
|
77
|
+
if (!existsSync(dirPath)) continue;
|
|
78
|
+
|
|
79
|
+
const files = readdirSync(dirPath).filter((f) => f.endsWith(".md"));
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const content = readFileSync(join(dirPath, file), "utf-8");
|
|
82
|
+
const slug = basename(file, ".md");
|
|
83
|
+
docs.push(parseMarkdown(content, slug));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Also check for getting-started.md at root
|
|
88
|
+
const gsPath = join(docsDir(), "getting-started.md");
|
|
89
|
+
if (existsSync(gsPath)) {
|
|
90
|
+
const content = readFileSync(gsPath, "utf-8");
|
|
91
|
+
docs.push(parseMarkdown(content, "getting-started"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return docs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read the .last-generated timestamp */
|
|
98
|
+
export function getDocsLastGenerated(): string | null {
|
|
99
|
+
const filePath = join(docsDir(), ".last-generated");
|
|
100
|
+
if (!existsSync(filePath)) return null;
|
|
101
|
+
return readFileSync(filePath, "utf-8").trim();
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Types for the Playbook documentation system
|
|
2
|
+
|
|
3
|
+
export interface DocManifest {
|
|
4
|
+
generated: string;
|
|
5
|
+
version: number;
|
|
6
|
+
sections: DocSection[];
|
|
7
|
+
journeys: DocJourney[];
|
|
8
|
+
metadata: {
|
|
9
|
+
totalDocs: number;
|
|
10
|
+
totalScreengrabs: number;
|
|
11
|
+
featuresCovered: number;
|
|
12
|
+
appSections: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DocSection {
|
|
17
|
+
slug: string;
|
|
18
|
+
title: string;
|
|
19
|
+
category: string;
|
|
20
|
+
path: string;
|
|
21
|
+
route: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
features: string[];
|
|
24
|
+
screengrabCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DocJourney {
|
|
28
|
+
slug: string;
|
|
29
|
+
title: string;
|
|
30
|
+
persona: string;
|
|
31
|
+
difficulty: string;
|
|
32
|
+
path: string;
|
|
33
|
+
sections: string[];
|
|
34
|
+
stepCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ParsedDoc {
|
|
38
|
+
frontmatter: Record<string, unknown>;
|
|
39
|
+
body: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UsageStage = "new" | "early" | "active" | "power";
|
|
44
|
+
|
|
45
|
+
export interface AdoptionEntry {
|
|
46
|
+
adopted: boolean;
|
|
47
|
+
depth: "none" | "light" | "deep";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JourneyCompletion {
|
|
51
|
+
completed: number;
|
|
52
|
+
total: number;
|
|
53
|
+
percentage: number;
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import {
|
|
3
|
+
tasks,
|
|
4
|
+
projects,
|
|
5
|
+
workflows,
|
|
6
|
+
schedules,
|
|
7
|
+
usageLedger,
|
|
8
|
+
} from "@/lib/db/schema";
|
|
9
|
+
import { count, isNotNull } from "drizzle-orm";
|
|
10
|
+
import type { UsageStage } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Compute the user's usage stage from DB state */
|
|
13
|
+
export async function getUsageStage(): Promise<UsageStage> {
|
|
14
|
+
const [taskCount, projectCount, workflowCount, scheduleCount, profileUsed] =
|
|
15
|
+
await Promise.all([
|
|
16
|
+
db
|
|
17
|
+
.select({ n: count() })
|
|
18
|
+
.from(tasks)
|
|
19
|
+
.then((r) => r[0]?.n ?? 0),
|
|
20
|
+
db
|
|
21
|
+
.select({ n: count() })
|
|
22
|
+
.from(projects)
|
|
23
|
+
.then((r) => r[0]?.n ?? 0),
|
|
24
|
+
db
|
|
25
|
+
.select({ n: count() })
|
|
26
|
+
.from(workflows)
|
|
27
|
+
.then((r) => r[0]?.n ?? 0),
|
|
28
|
+
db
|
|
29
|
+
.select({ n: count() })
|
|
30
|
+
.from(schedules)
|
|
31
|
+
.then((r) => r[0]?.n ?? 0),
|
|
32
|
+
db
|
|
33
|
+
.select({ n: count() })
|
|
34
|
+
.from(tasks)
|
|
35
|
+
.where(isNotNull(tasks.agentProfile))
|
|
36
|
+
.then((r) => r[0]?.n ?? 0),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// Power: has workflows + schedules + profiles
|
|
40
|
+
if (workflowCount > 0 && scheduleCount > 0 && profileUsed > 0) {
|
|
41
|
+
return "power";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Active: 6+ tasks OR 3+ projects OR any workflows/schedules
|
|
45
|
+
if (
|
|
46
|
+
taskCount >= 6 ||
|
|
47
|
+
projectCount >= 3 ||
|
|
48
|
+
workflowCount > 0 ||
|
|
49
|
+
scheduleCount > 0
|
|
50
|
+
) {
|
|
51
|
+
return "active";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Early: 1-5 tasks, <=2 projects
|
|
55
|
+
if (taskCount >= 1 || projectCount >= 1) {
|
|
56
|
+
return "early";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return "new";
|
|
60
|
+
}
|
|
@@ -80,6 +80,7 @@ export async function listPendingApprovalPayloads(
|
|
|
80
80
|
inArray(notifications.type, [
|
|
81
81
|
"permission_required",
|
|
82
82
|
"context_proposal",
|
|
83
|
+
"context_proposal_batch",
|
|
83
84
|
]),
|
|
84
85
|
isNull(notifications.response)
|
|
85
86
|
)
|
|
@@ -89,6 +90,7 @@ export async function listPendingApprovalPayloads(
|
|
|
89
90
|
|
|
90
91
|
return rows.map((row) => {
|
|
91
92
|
const isContextProposal = row.type === "context_proposal";
|
|
93
|
+
const isBatchProposal = row.type === "context_proposal_batch";
|
|
92
94
|
const parsedInput = parseNotificationToolInput(row.toolInput);
|
|
93
95
|
|
|
94
96
|
return {
|
|
@@ -97,16 +99,22 @@ export async function listPendingApprovalPayloads(
|
|
|
97
99
|
taskId: row.taskId,
|
|
98
100
|
workflowId: row.workflowId,
|
|
99
101
|
toolName: row.toolName,
|
|
100
|
-
permissionLabel:
|
|
101
|
-
? "
|
|
102
|
-
:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
permissionLabel: isBatchProposal
|
|
103
|
+
? "Workflow Learning"
|
|
104
|
+
: isContextProposal
|
|
105
|
+
? "Context Proposal"
|
|
106
|
+
: getPermissionKindLabel(row.toolName),
|
|
107
|
+
compactSummary: isBatchProposal
|
|
108
|
+
? `Batch of learned patterns from workflow execution`
|
|
109
|
+
: isContextProposal
|
|
110
|
+
? `Learned patterns proposed for profile "${row.toolName}"`
|
|
111
|
+
: buildPermissionSummary(row.toolName, parsedInput),
|
|
112
|
+
deepLink: isBatchProposal
|
|
113
|
+
? "/inbox"
|
|
114
|
+
: isContextProposal
|
|
115
|
+
? `/profiles/${row.toolName}`
|
|
116
|
+
: buildDeepLink(row.taskId, row.workflowId),
|
|
117
|
+
supportedActionIds: (isContextProposal || isBatchProposal)
|
|
110
118
|
? (["allow_once", "deny"] as ApprovalActionId[])
|
|
111
119
|
: [...APPROVAL_ACTION_IDS],
|
|
112
120
|
title: row.title,
|
|
@@ -8,10 +8,13 @@ let tempDir: string;
|
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
tempDir = mkdtempSync(join(tmpdir(), "stagent-budget-guardrails-"));
|
|
10
10
|
vi.resetModules();
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
vi.setSystemTime(new Date("2026-03-17T12:00:00.000Z"));
|
|
11
13
|
vi.stubEnv("STAGENT_DATA_DIR", tempDir);
|
|
12
14
|
});
|
|
13
15
|
|
|
14
16
|
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
15
18
|
vi.unstubAllEnvs();
|
|
16
19
|
rmSync(tempDir, { recursive: true, force: true });
|
|
17
20
|
});
|
|
@@ -21,8 +24,9 @@ async function loadModules() {
|
|
|
21
24
|
const schema = await import("@/lib/db/schema");
|
|
22
25
|
const ledger = await import("@/lib/usage/ledger");
|
|
23
26
|
const budgets = await import("../budget-guardrails");
|
|
27
|
+
const auth = await import("../auth");
|
|
24
28
|
|
|
25
|
-
return { db, ...schema, ...ledger, ...budgets };
|
|
29
|
+
return { db, ...schema, ...ledger, ...budgets, ...auth };
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
describe("budget guardrails", () => {
|
|
@@ -33,25 +37,22 @@ describe("budget guardrails", () => {
|
|
|
33
37
|
recordUsageLedgerEntry,
|
|
34
38
|
setBudgetPolicy,
|
|
35
39
|
enforceBudgetGuardrails,
|
|
40
|
+
setAuthSettings,
|
|
36
41
|
} = await loadModules();
|
|
37
42
|
|
|
43
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
44
|
+
await setAuthSettings({ method: "api_key" });
|
|
45
|
+
|
|
38
46
|
await setBudgetPolicy({
|
|
39
47
|
overall: {
|
|
40
|
-
|
|
41
|
-
monthlySpendCapUsd: null,
|
|
48
|
+
monthlySpendCapUsd: 0.36,
|
|
42
49
|
},
|
|
43
50
|
runtimes: {
|
|
44
51
|
"claude-code": {
|
|
45
|
-
|
|
46
|
-
monthlySpendCapUsd: null,
|
|
47
|
-
dailyTokenCap: null,
|
|
48
|
-
monthlyTokenCap: null,
|
|
52
|
+
monthlySpendCapUsd: 0.36,
|
|
49
53
|
},
|
|
50
54
|
"openai-codex-app-server": {
|
|
51
|
-
dailySpendCapUsd: null,
|
|
52
55
|
monthlySpendCapUsd: null,
|
|
53
|
-
dailyTokenCap: null,
|
|
54
|
-
monthlyTokenCap: null,
|
|
55
56
|
},
|
|
56
57
|
},
|
|
57
58
|
});
|
|
@@ -80,9 +81,10 @@ describe("budget guardrails", () => {
|
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
const rows = await db.select().from(notifications);
|
|
83
|
-
expect(rows).toHaveLength(
|
|
84
|
-
expect(rows
|
|
85
|
-
expect(rows
|
|
84
|
+
expect(rows).toHaveLength(2);
|
|
85
|
+
expect(rows.every((row) => row.type === "budget_alert")).toBe(true);
|
|
86
|
+
expect(rows.some((row) => row.title.includes("Overall daily spend"))).toBe(true);
|
|
87
|
+
expect(rows.some((row) => row.title.includes("Claude Code daily spend"))).toBe(true);
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
it("blocks new runtime activity, records a zero-cost ledger row, and fails queued tasks when requested", async () => {
|
|
@@ -95,8 +97,12 @@ describe("budget guardrails", () => {
|
|
|
95
97
|
setBudgetPolicy,
|
|
96
98
|
enforceTaskBudgetGuardrails,
|
|
97
99
|
BudgetLimitExceededError,
|
|
100
|
+
setAuthSettings,
|
|
98
101
|
} = await loadModules();
|
|
99
102
|
|
|
103
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
104
|
+
await setAuthSettings({ method: "api_key" });
|
|
105
|
+
|
|
100
106
|
const taskId = crypto.randomUUID();
|
|
101
107
|
const now = new Date();
|
|
102
108
|
|
|
@@ -120,21 +126,14 @@ describe("budget guardrails", () => {
|
|
|
120
126
|
|
|
121
127
|
await setBudgetPolicy({
|
|
122
128
|
overall: {
|
|
123
|
-
|
|
124
|
-
monthlySpendCapUsd: null,
|
|
129
|
+
monthlySpendCapUsd: 0.001,
|
|
125
130
|
},
|
|
126
131
|
runtimes: {
|
|
127
132
|
"claude-code": {
|
|
128
|
-
|
|
129
|
-
monthlySpendCapUsd: null,
|
|
130
|
-
dailyTokenCap: 100,
|
|
131
|
-
monthlyTokenCap: null,
|
|
133
|
+
monthlySpendCapUsd: 0.001,
|
|
132
134
|
},
|
|
133
135
|
"openai-codex-app-server": {
|
|
134
|
-
dailySpendCapUsd: null,
|
|
135
136
|
monthlySpendCapUsd: null,
|
|
136
|
-
dailyTokenCap: null,
|
|
137
|
-
monthlyTokenCap: null,
|
|
138
137
|
},
|
|
139
138
|
},
|
|
140
139
|
});
|
|
@@ -159,7 +158,7 @@ describe("budget guardrails", () => {
|
|
|
159
158
|
|
|
160
159
|
const [task] = await db.select().from(tasks);
|
|
161
160
|
expect(task?.status).toBe("failed");
|
|
162
|
-
expect(task?.result).toContain("
|
|
161
|
+
expect(task?.result).toContain("spend");
|
|
163
162
|
|
|
164
163
|
const ledgerRows = await db.select().from(usageLedger);
|
|
165
164
|
const blockedRow = ledgerRows.find((row) => row.status === "blocked");
|
|
@@ -176,6 +175,69 @@ describe("budget guardrails", () => {
|
|
|
176
175
|
|
|
177
176
|
const budgetNotifications = await db.select().from(notifications);
|
|
178
177
|
expect(budgetNotifications.at(-1)?.title).toContain("blocked");
|
|
179
|
-
expect(budgetNotifications.at(-1)?.body).toContain("
|
|
178
|
+
expect(budgetNotifications.at(-1)?.body).toContain("spend");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("derives daily spend caps from the monthly cap and auto-assigns a single configured runtime", async () => {
|
|
182
|
+
const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
|
|
183
|
+
await loadModules();
|
|
184
|
+
|
|
185
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
186
|
+
vi.stubEnv("OPENAI_API_KEY", "");
|
|
187
|
+
await setAuthSettings({ method: "api_key" });
|
|
188
|
+
|
|
189
|
+
await setBudgetPolicy({
|
|
190
|
+
overall: {
|
|
191
|
+
monthlySpendCapUsd: 310,
|
|
192
|
+
},
|
|
193
|
+
runtimes: {
|
|
194
|
+
"claude-code": {
|
|
195
|
+
monthlySpendCapUsd: 155,
|
|
196
|
+
},
|
|
197
|
+
"openai-codex-app-server": {
|
|
198
|
+
monthlySpendCapUsd: 155,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const snapshot = await getBudgetGuardrailSnapshot();
|
|
204
|
+
const overallDaily = snapshot.statuses.find(
|
|
205
|
+
(status) => status.scopeId === "overall" && status.window === "daily"
|
|
206
|
+
);
|
|
207
|
+
const claudeMonthly = snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd;
|
|
208
|
+
const openAIMonthly =
|
|
209
|
+
snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd;
|
|
210
|
+
|
|
211
|
+
expect(overallDaily?.limitValue).toBe(10_000_000);
|
|
212
|
+
expect(claudeMonthly).toBe(310);
|
|
213
|
+
expect(openAIMonthly).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("splits configured provider caps from the overall budget when both runtimes are configured", async () => {
|
|
217
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
218
|
+
vi.stubEnv("OPENAI_API_KEY", "sk-test-openai");
|
|
219
|
+
const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
|
|
220
|
+
await loadModules();
|
|
221
|
+
await setAuthSettings({ method: "api_key" });
|
|
222
|
+
|
|
223
|
+
await setBudgetPolicy({
|
|
224
|
+
overall: {
|
|
225
|
+
monthlySpendCapUsd: 300,
|
|
226
|
+
},
|
|
227
|
+
runtimes: {
|
|
228
|
+
"claude-code": {
|
|
229
|
+
monthlySpendCapUsd: 120,
|
|
230
|
+
},
|
|
231
|
+
"openai-codex-app-server": {
|
|
232
|
+
monthlySpendCapUsd: 180,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const snapshot = await getBudgetGuardrailSnapshot();
|
|
238
|
+
expect(snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd).toBe(120);
|
|
239
|
+
expect(snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd).toBe(
|
|
240
|
+
180
|
|
241
|
+
);
|
|
180
242
|
});
|
|
181
243
|
});
|