@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.
Files changed (38) hide show
  1. package/README.md +9 -0
  2. package/package.json +2 -2
  3. package/src/app/api/activity/route.ts +9 -23
  4. package/src/app/api/agents/route.ts +17 -1
  5. package/src/app/api/approvals/route.ts +13 -5
  6. package/src/app/api/credentials/[id]/route.ts +2 -0
  7. package/src/app/api/credentials/route.ts +4 -1
  8. package/src/app/api/goals/[id]/route.ts +28 -0
  9. package/src/app/api/goals/route.ts +33 -0
  10. package/src/app/api/protocols/templates/[id]/route.ts +2 -1
  11. package/src/app/api/protocols/templates/route.ts +2 -1
  12. package/src/app/api/settings/route.ts +13 -0
  13. package/src/app/home/page.tsx +3 -0
  14. package/src/cli/index.js +11 -0
  15. package/src/cli/spec.js +10 -0
  16. package/src/lib/server/activity/activity-log.ts +16 -1
  17. package/src/lib/server/agents/agent-service.ts +24 -11
  18. package/src/lib/server/approval-match.ts +14 -0
  19. package/src/lib/server/approvals/approval-hooks.ts +81 -0
  20. package/src/lib/server/approvals.ts +11 -0
  21. package/src/lib/server/connectors/swarmdock-tasks.ts +8 -0
  22. package/src/lib/server/connectors/swarmdock.ts +30 -0
  23. package/src/lib/server/execution-brief.ts +18 -0
  24. package/src/lib/server/goals/goal-repository.ts +19 -0
  25. package/src/lib/server/goals/goal-service.ts +143 -0
  26. package/src/lib/server/storage-normalization.ts +5 -0
  27. package/src/lib/server/storage.ts +57 -0
  28. package/src/lib/server/usage/cost-rollup.ts +124 -0
  29. package/src/lib/server/usage/usage-repository.ts +6 -0
  30. package/src/lib/validation/schemas.ts +3 -30
  31. package/src/lib/validation/server-schemas.ts +35 -0
  32. package/src/types/agent.ts +10 -0
  33. package/src/types/app-settings.ts +6 -0
  34. package/src/types/approval.ts +3 -0
  35. package/src/types/goal.ts +30 -0
  36. package/src/types/index.ts +1 -0
  37. package/src/types/misc.ts +2 -2
  38. 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
- export const ProtocolTemplateUpsertSchema = z.object({
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
+ })
@@ -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 {
@@ -7,6 +7,9 @@ export type ApprovalCategory =
7
7
  | 'task_tool'
8
8
  | 'human_loop'
9
9
  | 'connector_sender'
10
+ | 'agent_create'
11
+ | 'budget_change'
12
+ | 'delegation_enable'
10
13
 
11
14
  export interface ApprovalRequest {
12
15
  id: string
@@ -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
+ }
@@ -13,4 +13,5 @@ export * from './skill'
13
13
  export * from './run'
14
14
  export * from './approval'
15
15
  export * from './misc'
16
+ export * from './goal'
16
17
  export * from './swarmdock'
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