@swarmclawai/swarmclaw 1.2.9 → 1.3.1

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 (40) hide show
  1. package/README.md +17 -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-bidding.ts +2 -9
  22. package/src/lib/server/connectors/swarmdock-payloads.test.ts +18 -1
  23. package/src/lib/server/connectors/swarmdock-tasks.ts +10 -11
  24. package/src/lib/server/connectors/swarmdock.ts +111 -43
  25. package/src/lib/server/execution-brief.ts +18 -0
  26. package/src/lib/server/goals/goal-repository.ts +19 -0
  27. package/src/lib/server/goals/goal-service.ts +143 -0
  28. package/src/lib/server/storage-normalization.ts +5 -0
  29. package/src/lib/server/storage.ts +57 -0
  30. package/src/lib/server/usage/cost-rollup.ts +124 -0
  31. package/src/lib/server/usage/usage-repository.ts +6 -0
  32. package/src/lib/validation/schemas.ts +3 -30
  33. package/src/lib/validation/server-schemas.ts +35 -0
  34. package/src/types/agent.ts +10 -0
  35. package/src/types/app-settings.ts +6 -0
  36. package/src/types/approval.ts +3 -0
  37. package/src/types/goal.ts +30 -0
  38. package/src/types/index.ts +1 -0
  39. package/src/types/misc.ts +2 -2
  40. package/src/types/task.ts +2 -0
@@ -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