@swarmclawai/swarmclaw 1.2.9 → 1.3.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 +9 -0
- package/package.json +2 -2
- package/src/app/api/activity/route.ts +9 -23
- package/src/app/api/agents/route.ts +17 -1
- package/src/app/api/approvals/route.ts +13 -5
- package/src/app/api/credentials/[id]/route.ts +2 -0
- package/src/app/api/credentials/route.ts +4 -1
- package/src/app/api/goals/[id]/route.ts +28 -0
- package/src/app/api/goals/route.ts +33 -0
- package/src/app/api/protocols/templates/[id]/route.ts +2 -1
- package/src/app/api/protocols/templates/route.ts +2 -1
- package/src/app/api/settings/route.ts +13 -0
- package/src/app/home/page.tsx +3 -0
- package/src/cli/index.js +11 -0
- package/src/cli/spec.js +10 -0
- package/src/lib/server/activity/activity-log.ts +16 -1
- package/src/lib/server/agents/agent-service.ts +24 -11
- package/src/lib/server/approval-match.ts +14 -0
- package/src/lib/server/approvals/approval-hooks.ts +81 -0
- package/src/lib/server/approvals.ts +11 -0
- package/src/lib/server/connectors/swarmdock-tasks.ts +8 -0
- package/src/lib/server/connectors/swarmdock.ts +30 -0
- package/src/lib/server/execution-brief.ts +18 -0
- package/src/lib/server/goals/goal-repository.ts +19 -0
- package/src/lib/server/goals/goal-service.ts +143 -0
- package/src/lib/server/storage-normalization.ts +5 -0
- package/src/lib/server/storage.ts +57 -0
- package/src/lib/server/usage/cost-rollup.ts +124 -0
- package/src/lib/server/usage/usage-repository.ts +6 -0
- package/src/lib/validation/schemas.ts +3 -30
- package/src/lib/validation/server-schemas.ts +35 -0
- package/src/types/agent.ts +10 -0
- package/src/types/app-settings.ts +6 -0
- package/src/types/approval.ts +3 -0
- package/src/types/goal.ts +30 -0
- package/src/types/index.ts +1 -0
- package/src/types/misc.ts +2 -2
- package/src/types/task.ts +2 -0
|
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/server/sessions/session-repository'
|
|
|
12
12
|
import { loadSessionWorkingState } from '@/lib/server/working-state/service'
|
|
13
13
|
import { ensureRunContext } from '@/lib/server/run-context'
|
|
14
14
|
import { cleanText, cleanMultiline } from '@/lib/server/text-normalization'
|
|
15
|
+
import { resolveEffectiveGoal, getGoalChain, formatGoalChainForBrief } from '@/lib/server/goals/goal-service'
|
|
15
16
|
|
|
16
17
|
const MAX_PLAN_ITEMS = 8
|
|
17
18
|
const MAX_FACTS = 8
|
|
@@ -224,8 +225,25 @@ export function buildExecutionBriefContextBlock(
|
|
|
224
225
|
|| brief.evidenceRefs.length > 0,
|
|
225
226
|
)
|
|
226
227
|
if (!hasContent && brief.status === 'idle') return ''
|
|
228
|
+
// Resolve goal chain for the session's agent/task/project context
|
|
229
|
+
let goalBlock = ''
|
|
230
|
+
if (brief.sessionId) {
|
|
231
|
+
const session = getSession(brief.sessionId)
|
|
232
|
+
if (session) {
|
|
233
|
+
const goal = resolveEffectiveGoal({
|
|
234
|
+
agentId: session.agentId || null,
|
|
235
|
+
projectId: session.projectId || null,
|
|
236
|
+
})
|
|
237
|
+
if (goal) {
|
|
238
|
+
const chain = getGoalChain(goal.id)
|
|
239
|
+
goalBlock = formatGoalChainForBrief(chain)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
227
244
|
const sections = [
|
|
228
245
|
options?.title || '## Execution Brief',
|
|
246
|
+
goalBlock,
|
|
229
247
|
brief.parentContext ? `Parent context:\n${brief.parentContext}` : '',
|
|
230
248
|
brief.objective ? `Objective: ${brief.objective}` : '',
|
|
231
249
|
brief.summary ? `Summary: ${brief.summary}` : '',
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Goal } from '@/types'
|
|
2
|
+
import { loadGoals, loadGoal, upsertGoal, deleteGoalItem } from '@/lib/server/storage'
|
|
3
|
+
import { perf } from '@/lib/server/runtime/perf'
|
|
4
|
+
|
|
5
|
+
export function listGoals(): Record<string, Goal> {
|
|
6
|
+
return perf.measureSync('repository', 'goals.list', () => loadGoals()) as Record<string, Goal>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getGoal(id: string): Goal | null {
|
|
10
|
+
return perf.measureSync('repository', 'goals.get', () => loadGoal(id)) as Goal | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function saveGoal(id: string, goal: Goal): void {
|
|
14
|
+
perf.measureSync('repository', 'goals.upsert', () => upsertGoal(id, goal), { id })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function removeGoal(id: string): void {
|
|
18
|
+
perf.measureSync('repository', 'goals.delete', () => deleteGoalItem(id), { id })
|
|
19
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Goal, GoalLevel } from '@/types'
|
|
2
|
+
import { genId } from '@/lib/id'
|
|
3
|
+
import { listGoals, getGoal, saveGoal, removeGoal } from './goal-repository'
|
|
4
|
+
import { logActivity } from '@/lib/server/activity/activity-log'
|
|
5
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
6
|
+
|
|
7
|
+
export function getAllGoals(): Goal[] {
|
|
8
|
+
return Object.values(listGoals())
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getGoalById(id: string): Goal | null {
|
|
12
|
+
return getGoal(id)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createGoal(input: {
|
|
16
|
+
title: string
|
|
17
|
+
description?: string
|
|
18
|
+
level: GoalLevel
|
|
19
|
+
parentGoalId?: string | null
|
|
20
|
+
projectId?: string | null
|
|
21
|
+
agentId?: string | null
|
|
22
|
+
taskId?: string | null
|
|
23
|
+
objective: string
|
|
24
|
+
constraints?: string[]
|
|
25
|
+
successMetric?: string | null
|
|
26
|
+
budgetUsd?: number | null
|
|
27
|
+
deadlineAt?: number | null
|
|
28
|
+
}): Goal {
|
|
29
|
+
const id = genId()
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
const goal: Goal = {
|
|
32
|
+
id,
|
|
33
|
+
title: input.title,
|
|
34
|
+
description: input.description,
|
|
35
|
+
level: input.level,
|
|
36
|
+
parentGoalId: input.parentGoalId ?? null,
|
|
37
|
+
projectId: input.projectId ?? null,
|
|
38
|
+
agentId: input.agentId ?? null,
|
|
39
|
+
taskId: input.taskId ?? null,
|
|
40
|
+
objective: input.objective,
|
|
41
|
+
constraints: input.constraints ?? [],
|
|
42
|
+
successMetric: input.successMetric ?? null,
|
|
43
|
+
budgetUsd: input.budgetUsd ?? null,
|
|
44
|
+
deadlineAt: input.deadlineAt ?? null,
|
|
45
|
+
status: 'active',
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
}
|
|
49
|
+
saveGoal(id, goal)
|
|
50
|
+
logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Goal created: "${goal.title}" (${goal.level})` })
|
|
51
|
+
notify('goals')
|
|
52
|
+
return goal
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function updateGoal(id: string, updates: Partial<Omit<Goal, 'id' | 'createdAt'>>): Goal | null {
|
|
56
|
+
const existing = getGoal(id)
|
|
57
|
+
if (!existing) return null
|
|
58
|
+
const updated: Goal = {
|
|
59
|
+
...existing,
|
|
60
|
+
...updates,
|
|
61
|
+
id,
|
|
62
|
+
createdAt: existing.createdAt,
|
|
63
|
+
updatedAt: Date.now(),
|
|
64
|
+
}
|
|
65
|
+
saveGoal(id, updated)
|
|
66
|
+
logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Goal updated: "${updated.title}"` })
|
|
67
|
+
notify('goals')
|
|
68
|
+
return updated
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deleteGoal(id: string): boolean {
|
|
72
|
+
const existing = getGoal(id)
|
|
73
|
+
if (!existing) return false
|
|
74
|
+
removeGoal(id)
|
|
75
|
+
logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Goal deleted: "${existing.title}"` })
|
|
76
|
+
notify('goals')
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk the goal hierarchy to build a "why chain" from a specific goal up to the organization root.
|
|
82
|
+
* Returns goals in order from most specific to most general.
|
|
83
|
+
*/
|
|
84
|
+
export function getGoalChain(goalId: string): Goal[] {
|
|
85
|
+
const chain: Goal[] = []
|
|
86
|
+
const visited = new Set<string>()
|
|
87
|
+
let currentId: string | null | undefined = goalId
|
|
88
|
+
while (currentId && !visited.has(currentId)) {
|
|
89
|
+
visited.add(currentId)
|
|
90
|
+
const goal = getGoal(currentId)
|
|
91
|
+
if (!goal) break
|
|
92
|
+
chain.push(goal)
|
|
93
|
+
currentId = goal.parentGoalId
|
|
94
|
+
}
|
|
95
|
+
return chain
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the effective goal for a given context by walking the hierarchy:
|
|
100
|
+
* task goal → agent goal → project goal → organization default.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveEffectiveGoal(context: {
|
|
103
|
+
taskId?: string | null
|
|
104
|
+
agentId?: string | null
|
|
105
|
+
projectId?: string | null
|
|
106
|
+
}): Goal | null {
|
|
107
|
+
const goals = getAllGoals().filter((g) => g.status === 'active')
|
|
108
|
+
|
|
109
|
+
// 1. Task-level goal
|
|
110
|
+
if (context.taskId) {
|
|
111
|
+
const taskGoal = goals.find((g) => g.taskId === context.taskId)
|
|
112
|
+
if (taskGoal) return taskGoal
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Agent-level goal
|
|
116
|
+
if (context.agentId) {
|
|
117
|
+
const agentGoal = goals.find((g) => g.level === 'agent' && g.agentId === context.agentId)
|
|
118
|
+
if (agentGoal) return agentGoal
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. Project-level goal
|
|
122
|
+
if (context.projectId) {
|
|
123
|
+
const projectGoal = goals.find((g) => g.level === 'project' && g.projectId === context.projectId)
|
|
124
|
+
if (projectGoal) return projectGoal
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 4. Organization default
|
|
128
|
+
const orgGoal = goals.find((g) => g.level === 'organization')
|
|
129
|
+
return orgGoal ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format a goal chain as a concise text block for injection into agent execution briefs.
|
|
134
|
+
*/
|
|
135
|
+
export function formatGoalChainForBrief(chain: Goal[]): string {
|
|
136
|
+
if (chain.length === 0) return ''
|
|
137
|
+
const lines = chain.map((g, i) => {
|
|
138
|
+
const indent = ' '.repeat(i)
|
|
139
|
+
const label = g.level.charAt(0).toUpperCase() + g.level.slice(1)
|
|
140
|
+
return `${indent}${label}: ${g.title} — ${g.objective}`
|
|
141
|
+
})
|
|
142
|
+
return `Goal alignment:\n${lines.join('\n')}`
|
|
143
|
+
}
|
|
@@ -507,6 +507,11 @@ function normalizeStoredRecordInner(
|
|
|
507
507
|
agent.delegationEnabled = false
|
|
508
508
|
agent.heartbeatEnabled = false
|
|
509
509
|
}
|
|
510
|
+
// Persisted spend rollup defaults
|
|
511
|
+
if (typeof agent.spentMonthlyCents !== 'number') agent.spentMonthlyCents = 0
|
|
512
|
+
if (typeof agent.spentDailyCents !== 'number') agent.spentDailyCents = 0
|
|
513
|
+
if (typeof agent.spentHourlyCents !== 'number') agent.spentHourlyCents = 0
|
|
514
|
+
if (typeof agent.lastSpendRollupAt !== 'number') agent.lastSpendRollupAt = 0
|
|
510
515
|
// Org chart normalization
|
|
511
516
|
if (agent.orgChart && typeof agent.orgChart === 'object' && !Array.isArray(agent.orgChart)) {
|
|
512
517
|
const oc = agent.orgChart as Record<string, unknown>
|
|
@@ -157,6 +157,7 @@ const COLLECTIONS = [
|
|
|
157
157
|
'daemon_status',
|
|
158
158
|
'wallets',
|
|
159
159
|
'wallet_transactions',
|
|
160
|
+
'goals',
|
|
160
161
|
] as const
|
|
161
162
|
|
|
162
163
|
export type StorageCollection = (typeof COLLECTIONS)[number]
|
|
@@ -168,6 +169,10 @@ for (const table of COLLECTIONS) {
|
|
|
168
169
|
// Index for efficient protocol_run_events queries by runId
|
|
169
170
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_protocol_run_events_runid ON protocol_run_events (json_extract(data, '$.runId'))`)
|
|
170
171
|
|
|
172
|
+
// Indexes for efficient activity log queries
|
|
173
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity (json_extract(data, '$.timestamp'))`)
|
|
174
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_activity_entity ON activity (json_extract(data, '$.entityType'), json_extract(data, '$.entityId'))`)
|
|
175
|
+
|
|
171
176
|
// Singleton tables (single row)
|
|
172
177
|
db.exec(`CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`)
|
|
173
178
|
db.exec(`CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`)
|
|
@@ -1465,6 +1470,51 @@ export function logActivity(entry: {
|
|
|
1465
1470
|
notify('activity')
|
|
1466
1471
|
}
|
|
1467
1472
|
|
|
1473
|
+
/** Paginated activity query using SQL WHERE + LIMIT/OFFSET instead of loading the full collection. */
|
|
1474
|
+
export function queryActivity(filters: {
|
|
1475
|
+
entityType?: string
|
|
1476
|
+
entityId?: string
|
|
1477
|
+
actor?: string
|
|
1478
|
+
action?: string
|
|
1479
|
+
since?: number
|
|
1480
|
+
limit?: number
|
|
1481
|
+
offset?: number
|
|
1482
|
+
}): unknown[] {
|
|
1483
|
+
const conditions: string[] = []
|
|
1484
|
+
const params: unknown[] = []
|
|
1485
|
+
|
|
1486
|
+
if (filters.entityType) {
|
|
1487
|
+
conditions.push(`json_extract(data, '$.entityType') = ?`)
|
|
1488
|
+
params.push(filters.entityType)
|
|
1489
|
+
}
|
|
1490
|
+
if (filters.entityId) {
|
|
1491
|
+
conditions.push(`json_extract(data, '$.entityId') = ?`)
|
|
1492
|
+
params.push(filters.entityId)
|
|
1493
|
+
}
|
|
1494
|
+
if (filters.actor) {
|
|
1495
|
+
conditions.push(`json_extract(data, '$.actor') = ?`)
|
|
1496
|
+
params.push(filters.actor)
|
|
1497
|
+
}
|
|
1498
|
+
if (filters.action) {
|
|
1499
|
+
conditions.push(`json_extract(data, '$.action') = ?`)
|
|
1500
|
+
params.push(filters.action)
|
|
1501
|
+
}
|
|
1502
|
+
if (typeof filters.since === 'number' && Number.isFinite(filters.since)) {
|
|
1503
|
+
conditions.push(`json_extract(data, '$.timestamp') >= ?`)
|
|
1504
|
+
params.push(filters.since)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
1508
|
+
const limit = Math.min(200, Math.max(1, filters.limit ?? 50))
|
|
1509
|
+
const offset = Math.max(0, filters.offset ?? 0)
|
|
1510
|
+
|
|
1511
|
+
const sql = `SELECT data FROM activity ${where} ORDER BY json_extract(data, '$.timestamp') DESC LIMIT ? OFFSET ?`
|
|
1512
|
+
params.push(limit, offset)
|
|
1513
|
+
|
|
1514
|
+
const rows = db.prepare(sql).all(...params) as Array<{ data: string }>
|
|
1515
|
+
return rows.map((r) => JSON.parse(r.data))
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1468
1518
|
// --- Webhook Retry Queue ---
|
|
1469
1519
|
const webhookRetryQueueStore = createCollectionStore('webhook_retry_queue')
|
|
1470
1520
|
export const loadWebhookRetryQueue = webhookRetryQueueStore.load
|
|
@@ -1586,6 +1636,13 @@ export const loadWalletTransaction = walletTransactionsStore.loadItem
|
|
|
1586
1636
|
export const upsertWalletTransaction = walletTransactionsStore.upsert
|
|
1587
1637
|
export const deleteWalletTransaction = walletTransactionsStore.deleteItem
|
|
1588
1638
|
|
|
1639
|
+
// --- Goals ---
|
|
1640
|
+
const goalsStore = createCollectionStore('goals')
|
|
1641
|
+
export const loadGoals = goalsStore.load
|
|
1642
|
+
export const loadGoal = goalsStore.loadItem
|
|
1643
|
+
export const upsertGoal = goalsStore.upsert
|
|
1644
|
+
export const deleteGoalItem = goalsStore.deleteItem
|
|
1645
|
+
|
|
1589
1646
|
export function getSessionMessages(sessionId: string): Message[] {
|
|
1590
1647
|
const session = loadSession(sessionId)
|
|
1591
1648
|
return Array.isArray(session?.messages) ? session.messages : []
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Agent } from '@/types'
|
|
2
|
+
import { patchAgent } from '@/lib/server/agents/agent-repository'
|
|
3
|
+
import { logActivity } from '@/lib/server/activity/activity-log'
|
|
4
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import { log } from '@/lib/server/logger'
|
|
6
|
+
|
|
7
|
+
const TAG = 'cost-rollup'
|
|
8
|
+
|
|
9
|
+
const ONE_HOUR_MS = 60 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
function toWindowBoundaries(now: number) {
|
|
12
|
+
const d = new Date(now)
|
|
13
|
+
const dayStart = new Date(d)
|
|
14
|
+
dayStart.setUTCHours(0, 0, 0, 0)
|
|
15
|
+
const monthStart = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
|
|
16
|
+
return {
|
|
17
|
+
hourStartTs: now - ONE_HOUR_MS,
|
|
18
|
+
dayStartTs: dayStart.getTime(),
|
|
19
|
+
monthStartTs: monthStart.getTime(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Push-based cost rollup: atomically increments the agent's persisted spend
|
|
25
|
+
* fields and checks budgets. Called immediately after each usage record is appended.
|
|
26
|
+
*/
|
|
27
|
+
export function rollupCostToAgent(agentId: string, costUsd: number): void {
|
|
28
|
+
if (!agentId || !Number.isFinite(costUsd) || costUsd <= 0) return
|
|
29
|
+
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
const costCents = Math.round(costUsd * 100)
|
|
32
|
+
const { hourStartTs, dayStartTs, monthStartTs } = toWindowBoundaries(now)
|
|
33
|
+
|
|
34
|
+
const updated = patchAgent(agentId, (current) => {
|
|
35
|
+
if (!current) return null
|
|
36
|
+
const lastRollup = current.lastSpendRollupAt ?? 0
|
|
37
|
+
const lastBounds = toWindowBoundaries(lastRollup)
|
|
38
|
+
|
|
39
|
+
// Reset windows that have rolled over since last rollup
|
|
40
|
+
let hourly = current.spentHourlyCents ?? 0
|
|
41
|
+
let daily = current.spentDailyCents ?? 0
|
|
42
|
+
let monthly = current.spentMonthlyCents ?? 0
|
|
43
|
+
|
|
44
|
+
if (lastRollup < hourStartTs) hourly = 0
|
|
45
|
+
if (lastBounds.dayStartTs < dayStartTs) daily = 0
|
|
46
|
+
if (lastBounds.monthStartTs < monthStartTs) monthly = 0
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...current,
|
|
50
|
+
spentHourlyCents: hourly + costCents,
|
|
51
|
+
spentDailyCents: daily + costCents,
|
|
52
|
+
spentMonthlyCents: monthly + costCents,
|
|
53
|
+
lastSpendRollupAt: now,
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (!updated) return
|
|
58
|
+
|
|
59
|
+
// Check budgets and enforce
|
|
60
|
+
checkAndEnforceBudget(updated)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Checks agent's persisted spend against configured budgets and logs
|
|
65
|
+
* activity entries when thresholds are hit.
|
|
66
|
+
*/
|
|
67
|
+
function checkAndEnforceBudget(agent: Agent): void {
|
|
68
|
+
const WARNING_RATIO = 0.8
|
|
69
|
+
|
|
70
|
+
const windows = [
|
|
71
|
+
{ key: 'hourly' as const, budget: agent.hourlyBudget, spent: (agent.spentHourlyCents ?? 0) / 100 },
|
|
72
|
+
{ key: 'daily' as const, budget: agent.dailyBudget, spent: (agent.spentDailyCents ?? 0) / 100 },
|
|
73
|
+
{ key: 'monthly' as const, budget: agent.monthlyBudget, spent: (agent.spentMonthlyCents ?? 0) / 100 },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
for (const { key, budget, spent } of windows) {
|
|
77
|
+
if (!budget || !Number.isFinite(budget) || budget <= 0) continue
|
|
78
|
+
const ratio = spent / budget
|
|
79
|
+
|
|
80
|
+
if (ratio >= 1) {
|
|
81
|
+
log.warn(TAG, `Agent "${agent.name}" exceeded ${key} budget: $${spent.toFixed(4)} / $${budget.toFixed(2)}`)
|
|
82
|
+
logActivity({
|
|
83
|
+
entityType: 'budget',
|
|
84
|
+
entityId: agent.id,
|
|
85
|
+
action: 'budget_exceeded',
|
|
86
|
+
actor: 'system',
|
|
87
|
+
summary: `Agent "${agent.name}" exceeded ${key} budget: $${spent.toFixed(4)} / $${budget.toFixed(2)}`,
|
|
88
|
+
detail: { window: key, spent, budget, ratio },
|
|
89
|
+
})
|
|
90
|
+
notify('agents')
|
|
91
|
+
} else if (ratio >= WARNING_RATIO) {
|
|
92
|
+
logActivity({
|
|
93
|
+
entityType: 'budget',
|
|
94
|
+
entityId: agent.id,
|
|
95
|
+
action: 'budget_warning',
|
|
96
|
+
actor: 'system',
|
|
97
|
+
summary: `Agent "${agent.name}" nearing ${key} budget: $${spent.toFixed(4)} / $${budget.toFixed(2)} (${Math.round(ratio * 100)}%)`,
|
|
98
|
+
detail: { window: key, spent, budget, ratio },
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reset all agents' daily spend counters. Call at UTC midnight.
|
|
106
|
+
*/
|
|
107
|
+
export function resetDailySpends(agents: Record<string, Agent>): void {
|
|
108
|
+
for (const agent of Object.values(agents)) {
|
|
109
|
+
if ((agent.spentDailyCents ?? 0) > 0) {
|
|
110
|
+
patchAgent(agent.id, (current) => current ? { ...current, spentDailyCents: 0 } : null)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reset all agents' monthly spend counters. Call at UTC month boundary.
|
|
117
|
+
*/
|
|
118
|
+
export function resetMonthlySpends(agents: Record<string, Agent>): void {
|
|
119
|
+
for (const agent of Object.values(agents)) {
|
|
120
|
+
if ((agent.spentMonthlyCents ?? 0) > 0) {
|
|
121
|
+
patchAgent(agent.id, (current) => current ? { ...current, spentMonthlyCents: 0 } : null)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
saveUsage as saveStoredUsage,
|
|
9
9
|
} from '@/lib/server/storage'
|
|
10
10
|
import { perf } from '@/lib/server/runtime/perf'
|
|
11
|
+
import { rollupCostToAgent } from './cost-rollup'
|
|
11
12
|
|
|
12
13
|
export function loadUsage(): Record<string, UsageRecord[]> {
|
|
13
14
|
return perf.measureSync('repository', 'usage.list', () => loadStoredUsage())
|
|
@@ -19,6 +20,11 @@ export function saveUsage(data: Record<string, UsageRecord[]>): void {
|
|
|
19
20
|
|
|
20
21
|
export function appendUsage(sessionId: string, record: unknown): void {
|
|
21
22
|
perf.measureSync('repository', 'usage.append', () => appendStoredUsage(sessionId, record), { sessionId })
|
|
23
|
+
// Push-based cost rollup: update persisted spend fields and check budgets
|
|
24
|
+
const rec = record as Partial<UsageRecord>
|
|
25
|
+
if (rec.agentId && typeof rec.estimatedCost === 'number' && rec.estimatedCost > 0) {
|
|
26
|
+
rollupCostToAgent(rec.agentId, rec.estimatedCost)
|
|
27
|
+
}
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function getUsageSpendSince(minTimestamp: number): number {
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type { ProtocolStepDefinition } from '@/types'
|
|
3
|
-
import { validateStepDag, validateStepRefs } from '@/lib/server/protocols/step-dag-validation'
|
|
4
2
|
|
|
5
3
|
const OllamaModeSchema = z.enum(['local', 'cloud']).nullable().optional().default(null)
|
|
6
4
|
|
|
@@ -424,7 +422,9 @@ export const ProtocolRunCreateSchema = z.object({
|
|
|
424
422
|
entryStepId: z.string().nullable().optional().default(null),
|
|
425
423
|
})
|
|
426
424
|
|
|
427
|
-
|
|
425
|
+
/** Base protocol template schema without server-side DAG validation.
|
|
426
|
+
* Use ProtocolTemplateUpsertSchema from '@/lib/validation/server-schemas' for full server-side validation. */
|
|
427
|
+
export const ProtocolTemplateUpsertBaseSchema = z.object({
|
|
428
428
|
name: z.string().min(1, 'A template name is required'),
|
|
429
429
|
description: z.string().min(1, 'A template description is required'),
|
|
430
430
|
singleAgentAllowed: z.boolean().optional().default(true),
|
|
@@ -433,33 +433,6 @@ export const ProtocolTemplateUpsertSchema = z.object({
|
|
|
433
433
|
defaultPhases: z.array(ProtocolPhaseDefinitionSchema).optional().default([]),
|
|
434
434
|
steps: z.array(ProtocolStepDefinitionSchema).optional().default([]),
|
|
435
435
|
entryStepId: z.string().nullable().optional().default(null),
|
|
436
|
-
}).superRefine((value, ctx) => {
|
|
437
|
-
if (value.defaultPhases.length === 0 && value.steps.length === 0) {
|
|
438
|
-
ctx.addIssue({
|
|
439
|
-
code: z.ZodIssueCode.custom,
|
|
440
|
-
path: ['steps'],
|
|
441
|
-
message: 'Provide at least one phase or one step.',
|
|
442
|
-
})
|
|
443
|
-
}
|
|
444
|
-
if (value.steps.length > 0) {
|
|
445
|
-
const steps = value.steps as ProtocolStepDefinition[]
|
|
446
|
-
const dagResult = validateStepDag(steps)
|
|
447
|
-
if (!dagResult.valid) {
|
|
448
|
-
ctx.addIssue({
|
|
449
|
-
code: z.ZodIssueCode.custom,
|
|
450
|
-
path: ['steps'],
|
|
451
|
-
message: `Cycle detected in step dependencies: ${dagResult.cycle?.join(' → ')}`,
|
|
452
|
-
})
|
|
453
|
-
}
|
|
454
|
-
const invalidRefs = validateStepRefs(steps)
|
|
455
|
-
for (const ref of invalidRefs) {
|
|
456
|
-
ctx.addIssue({
|
|
457
|
-
code: z.ZodIssueCode.custom,
|
|
458
|
-
path: ['steps'],
|
|
459
|
-
message: `Step references unknown step ID: "${ref}"`,
|
|
460
|
-
})
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
436
|
})
|
|
464
437
|
|
|
465
438
|
export const ProtocolRunActionSchema = z.object({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { ProtocolStepDefinition } from '@/types'
|
|
3
|
+
import { validateStepDag, validateStepRefs } from '@/lib/server/protocols/step-dag-validation'
|
|
4
|
+
import { ProtocolTemplateUpsertBaseSchema } from './schemas'
|
|
5
|
+
|
|
6
|
+
/** Full protocol template upsert schema with server-side DAG validation.
|
|
7
|
+
* For client-side use (without server deps), use ProtocolTemplateUpsertBaseSchema from './schemas'. */
|
|
8
|
+
export const ProtocolTemplateUpsertSchema = ProtocolTemplateUpsertBaseSchema.superRefine((value, ctx) => {
|
|
9
|
+
if (value.defaultPhases.length === 0 && value.steps.length === 0) {
|
|
10
|
+
ctx.addIssue({
|
|
11
|
+
code: z.ZodIssueCode.custom,
|
|
12
|
+
path: ['steps'],
|
|
13
|
+
message: 'Provide at least one phase or one step.',
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
if (value.steps.length > 0) {
|
|
17
|
+
const steps = value.steps as ProtocolStepDefinition[]
|
|
18
|
+
const dagResult = validateStepDag(steps)
|
|
19
|
+
if (!dagResult.valid) {
|
|
20
|
+
ctx.addIssue({
|
|
21
|
+
code: z.ZodIssueCode.custom,
|
|
22
|
+
path: ['steps'],
|
|
23
|
+
message: `Cycle detected in step dependencies: ${dagResult.cycle?.join(' → ')}`,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
const invalidRefs = validateStepRefs(steps)
|
|
27
|
+
for (const ref of invalidRefs) {
|
|
28
|
+
ctx.addIssue({
|
|
29
|
+
code: z.ZodIssueCode.custom,
|
|
30
|
+
path: ['steps'],
|
|
31
|
+
message: `Step references unknown step ID: "${ref}"`,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
package/src/types/agent.ts
CHANGED
|
@@ -88,6 +88,8 @@ export interface Agent {
|
|
|
88
88
|
monthlyBudget?: number | null
|
|
89
89
|
dailyBudget?: number | null
|
|
90
90
|
hourlyBudget?: number | null
|
|
91
|
+
/** Reference to a Goal in the goal hierarchy. */
|
|
92
|
+
goalId?: string | null
|
|
91
93
|
autoRecovery?: boolean
|
|
92
94
|
proactiveMemory?: boolean
|
|
93
95
|
/** Auto-refresh a reviewed skill draft from meaningful chat turns for this agent. */
|
|
@@ -152,6 +154,14 @@ export interface Agent {
|
|
|
152
154
|
dailySpend?: number
|
|
153
155
|
/** Runtime-enriched: trailing 1-hour spend. Populated by GET /api/agents when hourlyBudget is set. */
|
|
154
156
|
hourlySpend?: number
|
|
157
|
+
/** Persisted: accumulated spend in current monthly window (USD). Updated on each usage event. */
|
|
158
|
+
spentMonthlyCents?: number
|
|
159
|
+
/** Persisted: accumulated spend in current daily window (USD). Updated on each usage event. */
|
|
160
|
+
spentDailyCents?: number
|
|
161
|
+
/** Persisted: accumulated spend in current hourly window (USD). Updated on each usage event. */
|
|
162
|
+
spentHourlyCents?: number
|
|
163
|
+
/** Timestamp of last spend rollup; used to detect window resets. */
|
|
164
|
+
lastSpendRollupAt?: number
|
|
155
165
|
maxFollowupChain?: number
|
|
156
166
|
|
|
157
167
|
// Orchestrator Mode
|
|
@@ -148,6 +148,12 @@ export interface AppSettings {
|
|
|
148
148
|
toolLoopCircuitBreaker?: number
|
|
149
149
|
// Per-extension settings (keyed by extensionId)
|
|
150
150
|
extensionSettings?: Record<string, Record<string, unknown>>
|
|
151
|
+
// Approval policies — opt-in governance gates for sensitive operations
|
|
152
|
+
approvalPolicies?: {
|
|
153
|
+
requireApprovalForAgentCreate?: boolean
|
|
154
|
+
requireApprovalForBudgetChange?: boolean
|
|
155
|
+
requireApprovalForDelegationEnable?: boolean
|
|
156
|
+
}
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export interface EstopState {
|
package/src/types/approval.ts
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type GoalLevel = 'organization' | 'team' | 'project' | 'agent' | 'task'
|
|
2
|
+
|
|
3
|
+
export type GoalStatus = 'active' | 'achieved' | 'abandoned'
|
|
4
|
+
|
|
5
|
+
export interface Goal {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
description?: string
|
|
9
|
+
level: GoalLevel
|
|
10
|
+
parentGoalId?: string | null
|
|
11
|
+
/** Link to a project (for project-level goals). */
|
|
12
|
+
projectId?: string | null
|
|
13
|
+
/** Link to an agent (for agent-level goals). */
|
|
14
|
+
agentId?: string | null
|
|
15
|
+
/** Link to a task (for task-level goals). */
|
|
16
|
+
taskId?: string | null
|
|
17
|
+
/** The concrete objective this goal achieves. */
|
|
18
|
+
objective: string
|
|
19
|
+
/** Constraints or guardrails on how the goal should be pursued. */
|
|
20
|
+
constraints?: string[]
|
|
21
|
+
/** How success is measured. */
|
|
22
|
+
successMetric?: string | null
|
|
23
|
+
/** Optional budget cap for this goal (USD). */
|
|
24
|
+
budgetUsd?: number | null
|
|
25
|
+
/** Optional deadline. */
|
|
26
|
+
deadlineAt?: number | null
|
|
27
|
+
status: GoalStatus
|
|
28
|
+
createdAt: number
|
|
29
|
+
updatedAt: number
|
|
30
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/types/misc.ts
CHANGED
|
@@ -126,9 +126,9 @@ export interface Chatroom {
|
|
|
126
126
|
|
|
127
127
|
export interface ActivityEntry {
|
|
128
128
|
id: string
|
|
129
|
-
entityType: 'agent' | 'task' | 'connector' | 'session' | 'webhook' | 'schedule' | 'delegation' | 'swarm' | 'chatroom' | 'coordination'
|
|
129
|
+
entityType: 'agent' | 'task' | 'connector' | 'session' | 'webhook' | 'schedule' | 'delegation' | 'swarm' | 'chatroom' | 'coordination' | 'approval' | 'settings' | 'budget' | 'credential'
|
|
130
130
|
entityId: string
|
|
131
|
-
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped' | 'queued' | 'completed' | 'failed' | 'archived' | 'restored' | 'approved' | 'rejected' | 'delegated' | 'queried' | 'spawned' | 'timeout' | 'cancelled' | 'incident' | 'running' | 'claimed'
|
|
131
|
+
action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped' | 'queued' | 'completed' | 'failed' | 'archived' | 'restored' | 'approved' | 'rejected' | 'delegated' | 'queried' | 'spawned' | 'timeout' | 'cancelled' | 'incident' | 'running' | 'claimed' | 'configured' | 'budget_exceeded' | 'budget_warning'
|
|
132
132
|
actor: 'user' | 'agent' | 'system' | 'daemon'
|
|
133
133
|
actorId?: string
|
|
134
134
|
summary: string
|
package/src/types/task.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface BoardTask {
|
|
|
35
35
|
rootTaskId?: string | null
|
|
36
36
|
projectId?: string
|
|
37
37
|
goalContract?: GoalContract | null
|
|
38
|
+
/** Reference to a Goal in the goal hierarchy. Takes precedence over goalContract when set. */
|
|
39
|
+
goalId?: string | null
|
|
38
40
|
cwd?: string | null
|
|
39
41
|
file?: string | null
|
|
40
42
|
sessionId?: string | null
|