diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 0d2a433b..a92484d3 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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([]) @@ -181,8 +178,6 @@ export default function PendingOperationsPage() { const [selectedOp, setSelectedOp] = useState(null) const [showCommitDialog, setShowCommitDialog] = useState(false) const [isCommitting, setIsCommitting] = useState(false) - const [recentAutoCommits, setRecentAutoCommits] = useState([]) - const [bannerDismissedAt, setBannerDismissedAt] = useState(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 && ( - - -
- -
-

- {newAutoCommits.length === 1 - ? '1 åtgärd auto-godkändes' - : `${newAutoCommits.length} åtgärder auto-godkändes`}{' '} - senaste dygnet -

-

- Granska vad agenten utförde utan din direkta godkännande. -

-
-
-
- - -
-
-
- )} - setActiveTab(v as PendingOperationStatus)}> Väntande @@ -356,7 +274,6 @@ export default function PendingOperationsPage() { Alla Från agent - Auto-godkända Hög risk @@ -417,13 +334,7 @@ export default function PendingOperationsPage() { {op.actor_label || op.actor_type} )} - {op.auto_committed_at && ( - - - Auto-godkänd - - )} - {op.status === 'committed' && !op.auto_committed_at && ( + {op.status === 'committed' && ( Godkänd diff --git a/app/(dashboard)/settings/api/page.tsx b/app/(dashboard)/settings/api/page.tsx index 3971b84d..a829a1d4 100644 --- a/app/(dashboard)/settings/api/page.tsx +++ b/app/(dashboard)/settings/api/page.tsx @@ -1,12 +1,10 @@ 'use client' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' -import { AgentAutoCommitSettings } from '@/components/settings/AgentAutoCommitSettings' export default function ApiSettingsPage() { return (
-
) diff --git a/components/settings/AgentAutoCommitSettings.tsx b/components/settings/AgentAutoCommitSettings.tsx deleted file mode 100644 index ad248249..00000000 --- a/components/settings/AgentAutoCommitSettings.tsx +++ /dev/null @@ -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('') - 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 ( - - - - - Auto-godkännande för agenter - - - 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. - - - - {loading ? ( -
- Laddar… -
- ) : ( - <> -
- -
- -

- Endast åtgärder klassificerade som låg risk (t.ex. skapa kund) körs - automatiskt. Hög-risk är alltid stoppad och hamnar i kön för granskning. -

-
-
- -
- - setMaxAmount(e.target.value)} - disabled={!enabled} - /> -

- Lämna tomt för ingen gräns. Åtgärder över beloppet faller tillbaka till manuell - granskning. -

-
- -
- -
- - )} -
-
- ) -} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index f2d94688..f6985ef4 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 message: string preview: Record 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 = { 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 } }, }, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index a5dfa27c..a19ff7eb 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts b/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts similarity index 84% rename from lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts rename to lib/pending-operations/__tests__/actor-and-risk.pg.test.ts index 7a47df7e..b0f6274f 100644 --- a/lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts +++ b/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts @@ -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() diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 50ddd4c0..739bbca2 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -60,8 +60,6 @@ function makePendingOp(overrides: Partial): 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', diff --git a/lib/pending-operations/__tests__/should-auto-commit.test.ts b/lib/pending-operations/__tests__/should-auto-commit.test.ts deleted file mode 100644 index 6d856747..00000000 --- a/lib/pending-operations/__tests__/should-auto-commit.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 833700ed..8e66f83b 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 = { - 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 { diff --git a/lib/pending-operations/should-auto-commit.ts b/lib/pending-operations/should-auto-commit.ts deleted file mode 100644 index ef7bcdcf..00000000 --- a/lib/pending-operations/should-auto-commit.ts +++ /dev/null @@ -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 { - 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, - } -} diff --git a/supabase/migrations/20260505120000_drop_agent_auto_commit.sql b/supabase/migrations/20260505120000_drop_agent_auto_commit.sql new file mode 100644 index 00000000..d30f63ed --- /dev/null +++ b/supabase/migrations/20260505120000_drop_agent_auto_commit.sql @@ -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'; diff --git a/tests/helpers.ts b/tests/helpers.ts index d640f300..94f24801 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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, diff --git a/types/index.ts b/types/index.ts index 747e89a7..a61e9885 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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