fix(mcp): correct fiscal_periods column ref + remove agent auto-commit (#394)
* fix(mcp): correct fiscal_periods column ref + remove agent auto-commit The MCP `gnubok_list_fiscal_periods` tool selected a non-existent `fiscal_periods.status` column, causing `column fiscal_periods.status does not exist` errors when agents called it. Fixed by selecting the real columns (`is_closed`, `locked_at`, `closed_at`, `opening_balances_set`) and deriving `status` in code. Also removes the agent auto-commit feature (settings card, gating logic, DB columns, tests). In its current shape only `create_customer` was auto-commitable, so the toggle changed nothing meaningful in practice while implying a level of agent autonomy that wasn't actually granted. The risk-tier infrastructure on `pending_operations` (actor model, risk_level) is kept since it's still used by the /pending UI filters. Migration `20260505120000_drop_agent_auto_commit.sql` drops: - pending_operations.auto_commit_eligible - pending_operations.auto_committed_at - company_settings.agent_auto_commit_enabled - company_settings.agent_auto_commit_max_amount Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): explicitly pick fields in gnubok_list_fiscal_periods response Greptile flagged that `...p` spreads raw DB columns (`is_closed`, `locked_at`, `closed_at`) into the tool response alongside the derived `status`. Drop the spread for an explicit field list so the tool contract is the derived status only — agents don't need to reason about raw columns, and future SELECT additions won't silently leak. Also drops `closed_at` from the SELECT since it wasn't read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5c52f24a49
commit
b05f0e59b9
@@ -19,7 +19,6 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Bot,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import type { PendingOperation, PendingOperationStatus } from '@/types'
|
||||
|
||||
@@ -168,9 +167,7 @@ function OperationPreview({ op }: { op: PendingOperation }) {
|
||||
}
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'agent' | 'auto_committed' | 'high_risk'
|
||||
|
||||
const AUTO_COMMIT_BANNER_DISMISS_KEY = 'gnubok.pending.autoCommitBannerDismissedAt'
|
||||
type SourceFilter = 'all' | 'agent' | 'high_risk'
|
||||
|
||||
export default function PendingOperationsPage() {
|
||||
const [operations, setOperations] = useState<PendingOperation[]>([])
|
||||
@@ -181,8 +178,6 @@ export default function PendingOperationsPage() {
|
||||
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(null)
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [isCommitting, setIsCommitting] = useState(false)
|
||||
const [recentAutoCommits, setRecentAutoCommits] = useState<PendingOperation[]>([])
|
||||
const [bannerDismissedAt, setBannerDismissedAt] = useState<number | null>(null)
|
||||
const { toast } = useToast()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
@@ -202,38 +197,6 @@ export default function PendingOperationsPage() {
|
||||
fetchOperations()
|
||||
}, [fetchOperations])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(AUTO_COMMIT_BANNER_DISMISS_KEY)
|
||||
: null
|
||||
setBannerDismissedAt(stored ? Number(stored) : null)
|
||||
|
||||
fetch('/api/pending-operations?status=committed&limit=50')
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
const ops: PendingOperation[] = json.data ?? []
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
||||
setRecentAutoCommits(
|
||||
ops.filter((o) => o.auto_committed_at && new Date(o.auto_committed_at).getTime() > cutoff)
|
||||
)
|
||||
})
|
||||
.catch(() => { /* silent */ })
|
||||
}, [])
|
||||
|
||||
function dismissAutoCommitBanner() {
|
||||
const now = Date.now()
|
||||
setBannerDismissedAt(now)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(AUTO_COMMIT_BANNER_DISMISS_KEY, String(now))
|
||||
}
|
||||
}
|
||||
|
||||
const newAutoCommits = recentAutoCommits.filter((o) =>
|
||||
bannerDismissedAt == null ||
|
||||
(o.auto_committed_at != null &&
|
||||
new Date(o.auto_committed_at).getTime() > bannerDismissedAt)
|
||||
)
|
||||
|
||||
async function handleCommit() {
|
||||
if (!selectedOp) return
|
||||
setIsCommitting(true)
|
||||
@@ -284,8 +247,6 @@ export default function PendingOperationsPage() {
|
||||
switch (sourceFilter) {
|
||||
case 'agent':
|
||||
return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron'
|
||||
case 'auto_committed':
|
||||
return Boolean(op.auto_committed_at)
|
||||
case 'high_risk':
|
||||
return op.risk_level === 'high'
|
||||
case 'all':
|
||||
@@ -301,49 +262,6 @@ export default function PendingOperationsPage() {
|
||||
description="Operationer som väntar på godkännande"
|
||||
/>
|
||||
|
||||
{newAutoCommits.length > 0 && (
|
||||
<Card className="border-sage/40 bg-sage/5">
|
||||
<CardContent className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-4 w-4 mt-0.5 text-sage-foreground" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">
|
||||
{newAutoCommits.length === 1
|
||||
? '1 åtgärd auto-godkändes'
|
||||
: `${newAutoCommits.length} åtgärder auto-godkändes`}{' '}
|
||||
senaste dygnet
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Granska vad agenten utförde utan din direkta godkännande.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setActiveTab('committed')
|
||||
setSourceFilter('auto_committed')
|
||||
dismissAutoCommitBanner()
|
||||
}}
|
||||
>
|
||||
Visa
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={dismissAutoCommitBanner}
|
||||
>
|
||||
Dölj
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending">Väntande</TabsTrigger>
|
||||
@@ -356,7 +274,6 @@ export default function PendingOperationsPage() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="agent">Från agent</TabsTrigger>
|
||||
<TabsTrigger value="auto_committed">Auto-godkända</TabsTrigger>
|
||||
<TabsTrigger value="high_risk">Hög risk</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -417,13 +334,7 @@ export default function PendingOperationsPage() {
|
||||
{op.actor_label || op.actor_type}
|
||||
</Badge>
|
||||
)}
|
||||
{op.auto_committed_at && (
|
||||
<Badge variant="outline" className="bg-sage/10 border-sage/40 text-sage-foreground">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
Auto-godkänd
|
||||
</Badge>
|
||||
)}
|
||||
{op.status === 'committed' && !op.auto_committed_at && (
|
||||
{op.status === 'committed' && (
|
||||
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Godkänd
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { AgentAutoCommitSettings } from '@/components/settings/AgentAutoCommitSettings'
|
||||
|
||||
export default function ApiSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<AgentAutoCommitSettings />
|
||||
<ApiKeysPanel />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Sparkles } from 'lucide-react'
|
||||
|
||||
export function AgentAutoCommitSettings() {
|
||||
const { toast } = useToast()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [maxAmount, setMaxAmount] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (cancelled) return
|
||||
const data = body?.data ?? {}
|
||||
setEnabled(Boolean(data.agent_auto_commit_enabled))
|
||||
setMaxAmount(
|
||||
data.agent_auto_commit_max_amount != null
|
||||
? String(data.agent_auto_commit_max_amount)
|
||||
: ''
|
||||
)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const parsedMax = maxAmount.trim() === '' ? null : Number(maxAmount)
|
||||
if (parsedMax !== null && (Number.isNaN(parsedMax) || parsedMax < 0)) {
|
||||
toast({ title: 'Ogiltigt belopp', description: 'Ange ett positivt tal eller lämna fältet tomt.', variant: 'destructive' })
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_auto_commit_enabled: enabled,
|
||||
agent_auto_commit_max_amount: parsedMax,
|
||||
}),
|
||||
})
|
||||
|
||||
setSaving(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: body?.error ?? 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Sparat', description: 'Inställningar för auto-godkännande uppdaterade.' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Auto-godkännande för agenter
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
När detta är aktivt får betrodda agenter (API-nycklar och Claude Desktop via OAuth)
|
||||
köra åtgärder med låg risk utan din godkännande. Hög-risk-åtgärder (periodlåsning,
|
||||
bokslut, fakturautskick m.m.) kräver alltid manuell granskning oavsett inställning.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Laddar…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="agent_auto_commit_enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="agent_auto_commit_enabled">Aktivera auto-godkännande</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-prose">
|
||||
Endast åtgärder klassificerade som <em>låg risk</em> (t.ex. skapa kund) körs
|
||||
automatiskt. Hög-risk är alltid stoppad och hamnar i kön för granskning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<Label htmlFor="agent_auto_commit_max_amount">
|
||||
Maxbelopp per åtgärd (SEK)
|
||||
</Label>
|
||||
<Input
|
||||
id="agent_auto_commit_max_amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="Ingen gräns"
|
||||
value={maxAmount}
|
||||
onChange={(e) => setMaxAmount(e.target.value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lämna tomt för ingen gräns. Åtgärder över beloppet faller tillbaka till manuell
|
||||
granskning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
Spara
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -29,8 +29,6 @@ import { dataResources, findResource, parseResourceQuery } from './resources'
|
||||
import { prompts, findPrompt } from './prompts'
|
||||
import { skills, findSkill, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
import {
|
||||
checkIdempotencyKey,
|
||||
storeIdempotencyResponse,
|
||||
@@ -38,7 +36,6 @@ import {
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError } from './tool-result'
|
||||
import type { PendingOperation } from '@/types'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||||
@@ -169,9 +166,6 @@ async function stagePendingOperation(
|
||||
operation_id?: string
|
||||
risk_level: 'low' | 'medium' | 'high'
|
||||
actor: ActorContext
|
||||
auto_committed: boolean
|
||||
auto_commit_reason?: string
|
||||
result?: Record<string, unknown>
|
||||
message: string
|
||||
preview: Record<string, unknown>
|
||||
next?: StageNextHint
|
||||
@@ -187,7 +181,6 @@ async function stagePendingOperation(
|
||||
dry_run: true,
|
||||
risk_level: riskLevel,
|
||||
actor,
|
||||
auto_committed: false,
|
||||
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
|
||||
preview: previewData,
|
||||
...(next ? { next } : {}),
|
||||
@@ -215,18 +208,6 @@ async function stagePendingOperation(
|
||||
}
|
||||
}
|
||||
|
||||
// Decide auto-commit eligibility BEFORE insert so we can persist the flag.
|
||||
const previewAmount =
|
||||
typeof previewData.amount === 'number' ? previewData.amount as number :
|
||||
typeof previewData.total === 'number' ? previewData.total as number :
|
||||
null
|
||||
|
||||
const decision = await shouldAutoCommit(supabase, companyId, {
|
||||
operationType,
|
||||
actorType: actor.type,
|
||||
amount: previewAmount,
|
||||
})
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('pending_operations')
|
||||
.insert({
|
||||
@@ -240,56 +221,18 @@ async function stagePendingOperation(
|
||||
actor_id: actor.id ?? null,
|
||||
actor_label: actor.label ?? null,
|
||||
risk_level: riskLevel,
|
||||
auto_commit_eligible: decision.eligible,
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`Failed to stage operation: ${error.message}`)
|
||||
|
||||
if (!decision.eligible) {
|
||||
const response = {
|
||||
staged: true,
|
||||
operation_id: data.id,
|
||||
risk_level: riskLevel,
|
||||
actor,
|
||||
auto_committed: false,
|
||||
auto_commit_reason: decision.reason,
|
||||
message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`,
|
||||
preview: previewData,
|
||||
...(next ? { next } : {}),
|
||||
} as const
|
||||
|
||||
if (options.idempotencyKey && requestHash) {
|
||||
await storeIdempotencyResponse(
|
||||
supabase, userId, companyId, options.idempotencyKey, requestHash,
|
||||
'success', { staged: true, operation_id: data.id, auto_committed: false, preview: previewData }
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Auto-commit path: invoke the dispatcher inline so the same audit
|
||||
// trail/event-emission logic runs as for human approvals.
|
||||
const commitResult = await commitPendingOperation(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
data as PendingOperation,
|
||||
{ isAutoCommit: true }
|
||||
)
|
||||
|
||||
const response = {
|
||||
staged: true,
|
||||
operation_id: data.id,
|
||||
risk_level: riskLevel,
|
||||
actor,
|
||||
auto_committed: commitResult.status === 'committed',
|
||||
auto_commit_reason: decision.reason,
|
||||
result: commitResult.data,
|
||||
message: commitResult.status === 'committed'
|
||||
? `Auto-committed by ${actor.label ?? actor.type} (risk: ${riskLevel}).`
|
||||
: `Auto-commit failed: ${commitResult.error ?? 'unknown'}. Review in the ${branding} web app.`,
|
||||
message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`,
|
||||
preview: previewData,
|
||||
...(next ? { next } : {}),
|
||||
} as const
|
||||
@@ -297,14 +240,7 @@ async function stagePendingOperation(
|
||||
if (options.idempotencyKey && requestHash) {
|
||||
await storeIdempotencyResponse(
|
||||
supabase, userId, companyId, options.idempotencyKey, requestHash,
|
||||
commitResult.status === 'committed' ? 'success' : 'error',
|
||||
{
|
||||
staged: true,
|
||||
operation_id: data.id,
|
||||
auto_committed: commitResult.status === 'committed',
|
||||
result: commitResult.data,
|
||||
preview: previewData,
|
||||
}
|
||||
'success', { staged: true, operation_id: data.id, preview: previewData }
|
||||
)
|
||||
}
|
||||
return response
|
||||
@@ -535,16 +471,13 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' },
|
||||
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
actor: { type: 'object' },
|
||||
auto_committed: { type: 'boolean' },
|
||||
auto_commit_reason: { type: 'string' },
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_replay: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
preview: { type: 'object' },
|
||||
result: { type: 'object' },
|
||||
next: { type: 'object' },
|
||||
},
|
||||
required: ['staged', 'risk_level', 'actor', 'auto_committed', 'message', 'preview'],
|
||||
required: ['staged', 'risk_level', 'actor', 'message', 'preview'],
|
||||
} as const
|
||||
|
||||
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
|
||||
@@ -2482,13 +2415,22 @@ export const tools: McpTool[] = [
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, status')
|
||||
.select('id, name, period_start, period_end, is_closed, locked_at, opening_balances_set')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
|
||||
return { periods: data ?? [], count: data?.length ?? 0 }
|
||||
const periods = (data ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
period_start: p.period_start,
|
||||
period_end: p.period_end,
|
||||
opening_balances_set: p.opening_balances_set,
|
||||
status: p.is_closed ? 'closed' : p.locked_at ? 'locked' : 'active',
|
||||
}))
|
||||
|
||||
return { periods, count: periods.length }
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -396,9 +396,6 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Agent auto-commit (low-risk ops staged by trusted agents)
|
||||
agent_auto_commit_enabled: z.boolean().optional(),
|
||||
agent_auto_commit_max_amount: z.number().nullable().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
|
||||
+6
-54
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* pg-real smoke tests for the four migrations introduced by the
|
||||
* AI-native streams (actor model, auto-commit, expanded op types, idempotency).
|
||||
* pg-real smoke tests for the migrations introduced by the AI-native streams
|
||||
* (actor model, expanded op types, idempotency).
|
||||
*
|
||||
* These don't replicate the unit-test coverage — they prove the schema and
|
||||
* constraints behave as the application code assumes when running against a
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
describe('pending_operations: actor model + risk + auto-commit columns', () => {
|
||||
describe('pending_operations: actor model + risk columns', () => {
|
||||
it('accepts the expanded actor_type and risk_level enums', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const pool = getPool()
|
||||
@@ -19,21 +19,19 @@ describe('pending_operations: actor model + risk + auto-commit columns', () => {
|
||||
id: string
|
||||
actor_type: string
|
||||
risk_level: string
|
||||
auto_commit_eligible: boolean
|
||||
}>(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data,
|
||||
actor_type, actor_id, actor_label, risk_level, auto_commit_eligible
|
||||
actor_type, actor_id, actor_label, risk_level
|
||||
) VALUES ($1, $2, 'create_customer', 'pg-real test', '{}', '{}',
|
||||
'api_key', NULL, 'Claude Desktop', 'low', true)
|
||||
RETURNING id, actor_type, risk_level, auto_commit_eligible`,
|
||||
'api_key', NULL, 'Claude Desktop', 'low')
|
||||
RETURNING id, actor_type, risk_level`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
actor_type: 'api_key',
|
||||
risk_level: 'low',
|
||||
auto_commit_eligible: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,20 +59,6 @@ describe('pending_operations: actor model + risk + auto-commit columns', () => {
|
||||
).rejects.toThrow(/check constraint|risk_level/i)
|
||||
})
|
||||
|
||||
it('blocks auto_committed_at when status is still pending', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data,
|
||||
actor_type, risk_level, auto_committed_at
|
||||
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}',
|
||||
'api_key', 'low', now())`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/pending_ops_auto_commit_status|check constraint/i)
|
||||
})
|
||||
|
||||
it('accepts the expanded operation_type enum (close_period, run_year_end, …)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const expandedTypes = [
|
||||
@@ -111,38 +95,6 @@ describe('audit_log: actor_type + actor_label columns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('company_settings: auto_commit columns', () => {
|
||||
it('exposes agent_auto_commit_enabled (default false) and agent_auto_commit_max_amount (NULL)', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
|
||||
const settings = await getPool().query<{
|
||||
enabled: boolean
|
||||
max_amount: string | null
|
||||
}>(
|
||||
`SELECT agent_auto_commit_enabled AS enabled,
|
||||
agent_auto_commit_max_amount AS max_amount
|
||||
FROM public.company_settings
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
// company_settings may or may not have a default row; if it doesn't, the
|
||||
// columns still exist on the table and we can inspect the catalog.
|
||||
if (settings.rows.length === 0) {
|
||||
const catalog = await getPool().query<{ name: string }>(
|
||||
`SELECT column_name AS name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'company_settings'
|
||||
AND column_name IN ('agent_auto_commit_enabled', 'agent_auto_commit_max_amount')`,
|
||||
)
|
||||
expect(catalog.rowCount).toBe(2)
|
||||
return
|
||||
}
|
||||
|
||||
expect(settings.rows[0]?.enabled).toBe(false)
|
||||
expect(settings.rows[0]?.max_amount).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency_keys table', () => {
|
||||
it('enforces unique (user_id, key) and 24h default expiry', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
@@ -60,8 +60,6 @@ function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'high',
|
||||
auto_commit_eligible: false,
|
||||
auto_committed_at: null,
|
||||
created_at: '2026-05-03T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-05-03T00:00:00Z',
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { shouldAutoCommit } from '../should-auto-commit'
|
||||
|
||||
function mockSettingsClient(settings: { agent_auto_commit_enabled?: boolean; agent_auto_commit_max_amount?: number | null } | null) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue(
|
||||
settings === null
|
||||
? { data: null, error: null }
|
||||
: { data: settings, error: null }
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('shouldAutoCommit', () => {
|
||||
it('rejects high-risk ops regardless of any other config', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'send_invoice',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.risk_level).toBe('high')
|
||||
expect(decision.reason).toContain('high-risk')
|
||||
})
|
||||
|
||||
it('rejects user actors (they approve via UI)', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'user',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('approve via the UI')
|
||||
})
|
||||
|
||||
it('rejects when company has not opted in', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: false })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('not opted in')
|
||||
})
|
||||
|
||||
it('rejects medium-risk ops in current phase', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'categorize_transaction',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('low-risk')
|
||||
})
|
||||
|
||||
it('approves low-risk ops from api_key with company opt-in', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
expect(decision.risk_level).toBe('low')
|
||||
})
|
||||
|
||||
it('blocks low-risk op when amount exceeds threshold', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: 5000,
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('exceeds')
|
||||
})
|
||||
|
||||
it('approves low-risk op when amount within threshold', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: 500,
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('approves low-risk op when amount missing (no threshold check applies)', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
// amount intentionally undefined
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('cron actors auto-commit non-high-risk without DB lookup', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'cron',
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
expect(decision.reason).toContain('Cron')
|
||||
})
|
||||
|
||||
it('cron actors still rejected for high-risk ops', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'send_invoice',
|
||||
actorType: 'cron',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to human approval when settings missing', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('Could not read company settings')
|
||||
})
|
||||
|
||||
it('treats negative amounts (refunds) by absolute value', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: -5000,
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('exceeds')
|
||||
})
|
||||
})
|
||||
@@ -79,12 +79,6 @@ export interface CommitResult {
|
||||
export interface CommitOptions {
|
||||
/** Email address used as cc on send_invoice (typically the human user's email). */
|
||||
userEmail?: string
|
||||
/**
|
||||
* When true, the op was auto-committed by a trusted agent (no human in the
|
||||
* loop). The status row is updated with `auto_committed_at` so the UI can
|
||||
* surface this on /pending and in audit reports.
|
||||
*/
|
||||
isAutoCommit?: boolean
|
||||
}
|
||||
|
||||
// ── Helper: ensure fiscal period covers the date ──────────────────
|
||||
@@ -1574,18 +1568,13 @@ export async function commitPendingOperation(
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const update: Record<string, unknown> = {
|
||||
status: 'committed',
|
||||
resolved_at: now,
|
||||
result_data: result.data || {},
|
||||
}
|
||||
if (opts.isAutoCommit) {
|
||||
update.auto_committed_at = now
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('pending_operations')
|
||||
.update(update)
|
||||
.update({
|
||||
status: 'committed',
|
||||
resolved_at: now,
|
||||
result_data: result.data || {},
|
||||
})
|
||||
.eq('id', pendingOp.id)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Decide whether a freshly-staged pending_operation should be auto-committed
|
||||
* by a trusted agent without human approval.
|
||||
*
|
||||
* Defense-in-depth: high-risk operations (period close, year-end, send_invoice,
|
||||
* etc.) are NEVER auto-committed regardless of company settings or actor
|
||||
* trust — that gate lives in risk-tiers.ts and is checked here before any
|
||||
* config lookup.
|
||||
*
|
||||
* Trust hierarchy:
|
||||
* - 'user' actors are humans clicking in the UI; auto-commit doesn't apply
|
||||
* (the click IS the approval)
|
||||
* - 'api_key' / 'mcp_oauth' actors are agents; eligible for auto-commit if
|
||||
* the company opts in and the op is low-risk
|
||||
* - 'cron' actors are system tasks; always auto-commit (they have no
|
||||
* human in the loop by design)
|
||||
*
|
||||
* Monetary threshold:
|
||||
* When `agent_auto_commit_max_amount` is set, any low-risk op with a
|
||||
* preview/payload amount above the threshold falls back to human approval.
|
||||
* The amount is read from the preview_data — callers should put it under
|
||||
* `amount` or `total` for the gate to find it. Missing amount → not blocked
|
||||
* by the threshold (safe for ops like create_customer where there's no
|
||||
* single dollar value).
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { isHighRisk, getRiskLevel, type RiskLevel } from './risk-tiers'
|
||||
|
||||
export type AutoCommitActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
|
||||
export interface AutoCommitInput {
|
||||
operationType: string
|
||||
actorType: AutoCommitActorType
|
||||
/** Optional monetary amount to check against agent_auto_commit_max_amount. */
|
||||
amount?: number | null
|
||||
}
|
||||
|
||||
export interface AutoCommitDecision {
|
||||
eligible: boolean
|
||||
reason: string
|
||||
risk_level: RiskLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pure-logic check that doesn't hit the DB. Used to short-circuit
|
||||
* obvious "no" cases before reading company_settings.
|
||||
*/
|
||||
function precheck(input: AutoCommitInput): AutoCommitDecision | null {
|
||||
const risk = getRiskLevel(input.operationType)
|
||||
|
||||
if (isHighRisk(input.operationType)) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: `Operation "${input.operationType}" is high-risk and never auto-committed.`,
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.actorType === 'user') {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'User actors approve via the UI; auto-commit does not apply.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
// Cron actors auto-commit non-high-risk regardless of company config.
|
||||
// Resolved here so we don't read company_settings unnecessarily.
|
||||
if (input.actorType === 'cron') {
|
||||
return {
|
||||
eligible: true,
|
||||
reason: 'Cron actor: auto-commit allowed for non-high-risk ops.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
// For api_key/mcp_oauth: only low-risk is auto-committable in this phase.
|
||||
// Reject medium-risk before the company_settings lookup so callers don't pay
|
||||
// for a DB read that can't succeed.
|
||||
if (risk !== 'low') {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Only low-risk operations are auto-committable in the current phase.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function shouldAutoCommit(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: AutoCommitInput
|
||||
): Promise<AutoCommitDecision> {
|
||||
const pre = precheck(input)
|
||||
if (pre) return pre
|
||||
|
||||
const risk = getRiskLevel(input.operationType)
|
||||
|
||||
// api_key / mcp_oauth + low-risk: gated by company opt-in and threshold.
|
||||
const { data: settings, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('agent_auto_commit_enabled, agent_auto_commit_max_amount')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !settings) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Could not read company settings; defaulting to human approval.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.agent_auto_commit_enabled) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Company has not opted in to agent auto-commit.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
const max = settings.agent_auto_commit_max_amount
|
||||
const amount = input.amount
|
||||
if (max != null && amount != null && Math.abs(amount) > Number(max)) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: `Amount ${amount} exceeds company auto-commit threshold ${max}.`,
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
reason: 'Low-risk op, trusted actor, company opted in, amount within limit.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Migration: Drop agent auto-commit feature.
|
||||
--
|
||||
-- Removes the columns and constraints introduced by:
|
||||
-- - 20260430120000_pending_operations_actor_and_risk.sql (auto_commit_*)
|
||||
-- - 20260501120000_company_settings_auto_commit.sql (agent_auto_commit_*)
|
||||
--
|
||||
-- The actor model and risk_level are kept — those are still useful for
|
||||
-- attribution and the /pending UI's "Hög risk" filter. We're only ripping
|
||||
-- out the auto-commit half.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. pending_operations: drop auto-commit columns + supporting constraint/index
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP CONSTRAINT IF EXISTS pending_ops_auto_commit_status;
|
||||
|
||||
DROP INDEX IF EXISTS public.idx_pending_ops_auto_committed;
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP COLUMN IF EXISTS auto_commit_eligible,
|
||||
DROP COLUMN IF EXISTS auto_committed_at;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. company_settings: drop the opt-in toggle and threshold
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP COLUMN IF EXISTS agent_auto_commit_enabled,
|
||||
DROP COLUMN IF EXISTS agent_auto_commit_max_amount;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. PostgREST schema reload
|
||||
-- =============================================================================
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -537,8 +537,6 @@ export function makeCompanySettings(
|
||||
onboarding_complete: true,
|
||||
sector_slug: null,
|
||||
is_sandbox: false,
|
||||
agent_auto_commit_enabled: false,
|
||||
agent_auto_commit_max_amount: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
|
||||
@@ -254,12 +254,6 @@ export interface CompanySettings {
|
||||
// Sandbox
|
||||
is_sandbox: boolean
|
||||
|
||||
// Agent auto-commit. When enabled, low-risk pending_operations staged by
|
||||
// trusted agents (api_key, mcp_oauth) skip human review. High-risk ops
|
||||
// (period close, year-end, send_invoice, etc.) always require approval.
|
||||
agent_auto_commit_enabled: boolean
|
||||
agent_auto_commit_max_amount: number | null
|
||||
|
||||
// Timestamps
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -1349,9 +1343,6 @@ export interface PendingOperation {
|
||||
actor_id: string | null
|
||||
actor_label: string | null
|
||||
risk_level: PendingOperationRiskLevel
|
||||
// Stream 2 Phase 2: auto-commit tracking
|
||||
auto_commit_eligible: boolean
|
||||
auto_committed_at: string | null
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
updated_at: string
|
||||
|
||||
Reference in New Issue
Block a user