Add metering for Automate

This commit is contained in:
James Brunton
2026-09-01 15:26:51 +01:00
parent 31d52d4c32
commit d12be8ea53
10 changed files with 707 additions and 2 deletions
@@ -0,0 +1,63 @@
package stirling.software.proprietary.automation;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.accountlink.EntitlementCache;
import stirling.software.proprietary.accountlink.InstanceEntitlement;
import stirling.software.proprietary.accountlink.UsageMeterService;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
/**
* Charges a client-side Automate run on a linked self-hosted instance: computes the input set's
* doc-units with the instance's synced {@link UnitCalcPolicy} and accrues them as {@link
* BillingCategory#AUTOMATION}, exactly as the {@code InstanceEntitlementInterceptor} meters a
* policy's tool sub-steps. Metering is instance-scoped, so no per-user context is needed.
*
* <p>Metering itself is optional (the {@link UsageMeterService} bean is absent when {@code
* metering.enabled=false}); a null signature is passed so each run is its own charge (the
* standalone semantic - no workflow-window dedup).
*/
@Slf4j
@Component
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkAutomationRunBiller implements AutomationRunBiller {
private final EntitlementCache entitlementCache;
private final ObjectProvider<UsageMeterService> meterProvider;
public AccountLinkAutomationRunBiller(
EntitlementCache entitlementCache, ObjectProvider<UsageMeterService> meterProvider) {
this.entitlementCache = entitlementCache;
this.meterProvider = meterProvider;
}
@Override
public void recordAutomationRun(List<FileSize> inputs) {
if (inputs.isEmpty()) {
return;
}
UsageMeterService meter = meterProvider.getIfAvailable();
if (meter == null) {
return; // metering switch off
}
InstanceEntitlement ent = entitlementCache.current().orElse(null);
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
// Not yet synced (no policy/period) - can't compute units; skip until next sync.
return;
}
UnitCalcPolicy policy = ent.unitCalcPolicy();
long units = DocumentUnitCalculator.unitsForGroup(inputs, policy);
meter.accrue(ent.periodStart(), BillingCategory.AUTOMATION, units, null);
}
}
@@ -0,0 +1,126 @@
package stirling.software.proprietary.automation;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
/**
* Meters + audits a client-side Automate run so a browser-run workflow bills like the equivalent
* server-side policy. Side-effect only; does no processing itself. The frontend dispatches this
* once, after the run completes.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/automate")
public class AutomationMeterController {
/** Cap on input documents accepted per call, guarding against a hostile client payload. */
private static final int MAX_INPUTS = 10_000;
/** Cap on operations recorded for the audit label. */
private static final int MAX_OPERATIONS = 1_000;
private final ObjectProvider<AutomationRunBiller> biller;
public AutomationMeterController(ObjectProvider<AutomationRunBiller> biller) {
this.biller = biller;
}
@PostMapping("/meter")
@Operation(
summary = "Meter a client-side Automate run",
description =
"Records billing + audit for an automation performed in the browser. Does no"
+ " processing itself. Dispatched by the frontend, not for direct use.")
public ResponseEntity<Void> meterAutomationRun(
@RequestBody(required = false) AutomationMeterRequest body,
HttpServletRequest request) {
List<FileSize> inputs = sanitizeInputs(body);
if (inputs.isEmpty()) {
// No billable input set - nothing to charge (an empty run still returns 202).
return ResponseEntity.accepted().build();
}
String automationName =
body != null && body.automationName() != null && !body.automationName().isBlank()
? body.automationName()
: "Automation";
List<String> steps = sanitizeOperations(body);
// Stamp the run so the audit trail records it as an automation, like a server-run policy.
request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, automationName);
request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps);
AutomationRunBiller runBiller = biller.getIfAvailable();
if (runBiller != null) {
try {
runBiller.recordAutomationRun(inputs);
} catch (RuntimeException e) {
log.warn(
"[automate meter] billing failed; the run already completed unbilled: {}",
e.getMessage());
}
}
return ResponseEntity.accepted().build();
}
/** Clamp to a sane count and drop malformed entries; negatives are treated as zero. */
private static List<FileSize> sanitizeInputs(AutomationMeterRequest body) {
if (body == null || body.inputs() == null) {
return List.of();
}
List<FileSize> out = new ArrayList<>();
for (InputDoc doc : body.inputs()) {
if (doc == null) {
continue;
}
int pages = doc.pages() != null ? Math.max(0, doc.pages()) : 0;
long bytes = doc.bytes() != null ? Math.max(0L, doc.bytes()) : 0L;
out.add(new FileSize(pages, bytes));
if (out.size() >= MAX_INPUTS) {
break;
}
}
return out;
}
private static List<String> sanitizeOperations(AutomationMeterRequest body) {
if (body == null || body.operations() == null) {
return List.of();
}
List<String> out = new ArrayList<>();
for (String op : body.operations()) {
if (op != null && !op.isBlank()) {
out.add(op.trim());
}
if (out.size() >= MAX_OPERATIONS) {
break;
}
}
return out;
}
/** Frontend payload: the run's name, its operation ids, and per-input page/byte facts. */
public record AutomationMeterRequest(
String automationName, List<String> operations, List<InputDoc> inputs) {}
/** One input document's page count (0 for non-PDF / unknown) and byte size. */
public record InputDoc(Integer pages, Long bytes) {}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.automation;
import java.util.List;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
/**
* Meters one client-side Automate run. The Automate tool runs its steps in the browser (calling
* each tool's normal endpoint), so no automation sub-step reaches the billing interceptors - this
* biller is how that run is charged instead. SaaS and a linked self-hosted instance each provide an
* implementation; other flavors have no bean and the run is recorded for audit but not charged.
*
* <p>Charged on the input document set's doc-units, once per run, so an Automate workflow costs the
* same as the equivalent server-side policy over the same inputs (see {@link
* stirling.software.proprietary.billing.DocumentUnitCalculator}).
*/
public interface AutomationRunBiller {
/**
* Charge one Automate run over {@code inputs} (page/byte facts of the original input files).
*/
void recordAutomationRun(List<FileSize> inputs);
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.automation;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.ObjectProvider;
import stirling.software.proprietary.accountlink.EntitlementCache;
import stirling.software.proprietary.accountlink.EntitlementState;
import stirling.software.proprietary.accountlink.InstanceEntitlement;
import stirling.software.proprietary.accountlink.UsageMeterService;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
class AccountLinkAutomationRunBillerTest {
private static final UnitCalcPolicy POLICY = new UnitCalcPolicy(10, 1_000_000L, 1, 1000);
private static final LocalDateTime PERIOD = LocalDateTime.of(2026, 9, 1, 0, 0);
@SuppressWarnings("unchecked")
private static ObjectProvider<UsageMeterService> providerOf(UsageMeterService meter) {
ObjectProvider<UsageMeterService> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(meter);
return provider;
}
private static InstanceEntitlement entitlement(UnitCalcPolicy policy, LocalDateTime period) {
return new InstanceEntitlement(
true, 0L, 0L, null, EntitlementState.OK, policy, period, null);
}
@Test
void accruesComputedUnitsAsAutomation() {
EntitlementCache cache = mock(EntitlementCache.class);
when(cache.current()).thenReturn(Optional.of(entitlement(POLICY, PERIOD)));
UsageMeterService meter = mock(UsageMeterService.class);
AccountLinkAutomationRunBiller biller =
new AccountLinkAutomationRunBiller(cache, providerOf(meter));
// 25 pages -> ceil(25/10)=3 page units; 5000 bytes -> 1 byte unit; max = 3.
biller.recordAutomationRun(List.of(new FileSize(25, 5000L)));
verify(meter).accrue(PERIOD, BillingCategory.AUTOMATION, 3L, null);
}
@Test
void noAccrueWhenMeterAbsent() {
EntitlementCache cache = mock(EntitlementCache.class);
AccountLinkAutomationRunBiller biller =
new AccountLinkAutomationRunBiller(cache, providerOf(null));
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
verify(cache, never()).current();
}
@Test
void noAccrueWhenEntitlementUnknown() {
EntitlementCache cache = mock(EntitlementCache.class);
when(cache.current()).thenReturn(Optional.empty());
UsageMeterService meter = mock(UsageMeterService.class);
AccountLinkAutomationRunBiller biller =
new AccountLinkAutomationRunBiller(cache, providerOf(meter));
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
verify(meter, never())
.accrue(
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.any());
}
@Test
void noAccrueWhenPeriodOrPolicyMissing() {
EntitlementCache cache = mock(EntitlementCache.class);
when(cache.current())
.thenReturn(Optional.of(entitlement(POLICY, null)))
.thenReturn(Optional.of(entitlement(null, PERIOD)));
UsageMeterService meter = mock(UsageMeterService.class);
AccountLinkAutomationRunBiller biller =
new AccountLinkAutomationRunBiller(cache, providerOf(meter));
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
verify(meter, never())
.accrue(
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.anyLong(),
ArgumentMatchers.any());
}
@Test
void noAccrueForEmptyInputs() {
EntitlementCache cache = mock(EntitlementCache.class);
UsageMeterService meter = mock(UsageMeterService.class);
AccountLinkAutomationRunBiller biller =
new AccountLinkAutomationRunBiller(cache, providerOf(meter));
biller.recordAutomationRun(List.of());
verify(cache, never()).current();
}
}
@@ -0,0 +1,121 @@
package stirling.software.proprietary.automation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.automation.AutomationMeterController.AutomationMeterRequest;
import stirling.software.proprietary.automation.AutomationMeterController.InputDoc;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
class AutomationMeterControllerTest {
@SuppressWarnings("unchecked")
private static ObjectProvider<AutomationRunBiller> providerOf(AutomationRunBiller biller) {
ObjectProvider<AutomationRunBiller> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(biller);
return provider;
}
private static AutomationMeterRequest req(List<InputDoc> inputs) {
return new AutomationMeterRequest("My run", List.of("compress", "rotate"), inputs);
}
@Test
void billsInputsAndStampsAudit() {
AutomationRunBiller biller = mock(AutomationRunBiller.class);
AutomationMeterController controller = new AutomationMeterController(providerOf(biller));
MockHttpServletRequest http = new MockHttpServletRequest();
var response =
controller.meterAutomationRun(
req(List.of(new InputDoc(3, 1000L), new InputDoc(0, 2048L))), http);
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
ArgumentCaptor<List<FileSize>> captor = ArgumentCaptor.forClass(List.class);
verify(biller).recordAutomationRun(captor.capture());
assertEquals(List.of(new FileSize(3, 1000L), new FileSize(0, 2048L)), captor.getValue());
assertEquals("My run", http.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME));
assertEquals(
List.of("compress", "rotate"),
http.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS));
}
@Test
void emptyInputsDoesNotBill() {
AutomationRunBiller biller = mock(AutomationRunBiller.class);
AutomationMeterController controller = new AutomationMeterController(providerOf(biller));
var response = controller.meterAutomationRun(req(List.of()), new MockHttpServletRequest());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
verify(biller, never()).recordAutomationRun(org.mockito.ArgumentMatchers.anyList());
}
@Test
void nullBodyIsAccepted() {
AutomationRunBiller biller = mock(AutomationRunBiller.class);
AutomationMeterController controller = new AutomationMeterController(providerOf(biller));
var response = controller.meterAutomationRun(null, new MockHttpServletRequest());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
verify(biller, never()).recordAutomationRun(org.mockito.ArgumentMatchers.anyList());
}
@Test
void noBillerBeanStillAccepts() {
AutomationMeterController controller = new AutomationMeterController(providerOf(null));
var response =
controller.meterAutomationRun(
req(List.of(new InputDoc(1, 10L))), new MockHttpServletRequest());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
}
@Test
void billingExceptionIsSwallowed() {
AutomationRunBiller biller = mock(AutomationRunBiller.class);
org.mockito.Mockito.doThrow(new RuntimeException("boom"))
.when(biller)
.recordAutomationRun(org.mockito.ArgumentMatchers.anyList());
AutomationMeterController controller = new AutomationMeterController(providerOf(biller));
var response =
controller.meterAutomationRun(
req(List.of(new InputDoc(1, 10L))), new MockHttpServletRequest());
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
}
@Test
void clampsNegativesAndCapsInputCount() {
AutomationRunBiller biller = mock(AutomationRunBiller.class);
AutomationMeterController controller = new AutomationMeterController(providerOf(biller));
List<InputDoc> many =
IntStream.range(0, 10_050).mapToObj(i -> new InputDoc(-5, -1L)).toList();
controller.meterAutomationRun(
new AutomationMeterRequest(null, null, many), new MockHttpServletRequest());
ArgumentCaptor<List<FileSize>> captor = ArgumentCaptor.forClass(List.class);
verify(biller).recordAutomationRun(captor.capture());
List<FileSize> billed = captor.getValue();
assertEquals(10_000, billed.size());
assertTrue(billed.stream().allMatch(f -> f.pages() == 0 && f.bytes() == 0L));
}
}
@@ -0,0 +1,71 @@
package stirling.software.saas.payg.charge;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.automation.AutomationRunBiller;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Charges one client-side Automate run as a standalone AUTOMATION job, on the input set's doc-units
* with the team's effective {@link PricingPolicy} - so a browser-run workflow costs the same as the
* equivalent server-side policy over the same inputs.
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
public class SaasAutomationRunBiller implements AutomationRunBiller {
private final UserRepository userRepository;
private final PricingPolicyService pricingPolicyService;
private final JobChargeService jobChargeService;
@Override
public void recordAutomationRun(List<FileSize> inputs) {
if (inputs.isEmpty()) {
return;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
if (user == null || user.getTeam() == null) {
return;
}
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(user.getTeam().getId());
UnitCalcPolicy unitCalc =
new UnitCalcPolicy(
policy.getDocPagesPerUnit(),
policy.getDocBytesPerUnit(),
policy.getMinChargeUnits(),
policy.getFileUnitCap());
int units = DocumentUnitCalculator.unitsForGroup(inputs, unitCalc);
JobSource source =
auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB;
ChargeContext ctx =
new ChargeContext(
user.getId(),
user.getTeam().getId(),
source,
ProcessType.AUTOMATION,
BillingCategory.AUTOMATION);
// chargeStandalone re-resolves the effective policy and applies its minChargeUnits floor.
jobChargeService.chargeStandalone(ctx, units);
}
}
@@ -0,0 +1,119 @@
package stirling.software.saas.payg.charge;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
class SaasAutomationRunBillerTest {
private final UserRepository userRepository = mock(UserRepository.class);
private final PricingPolicyService pricingPolicyService = mock(PricingPolicyService.class);
private final JobChargeService jobChargeService = mock(JobChargeService.class);
private final SaasAutomationRunBiller biller =
new SaasAutomationRunBiller(userRepository, pricingPolicyService, jobChargeService);
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private static User userWithTeam(long userId, long teamId) {
Team team = mock(Team.class);
when(team.getId()).thenReturn(teamId);
User user = mock(User.class);
when(user.getId()).thenReturn(userId);
when(user.getTeam()).thenReturn(team);
return user;
}
private static PricingPolicy policy() {
PricingPolicy policy = mock(PricingPolicy.class);
when(policy.getDocPagesPerUnit()).thenReturn(10);
when(policy.getDocBytesPerUnit()).thenReturn(1_000_000L);
when(policy.getMinChargeUnits()).thenReturn(1);
when(policy.getFileUnitCap()).thenReturn(1000);
return policy;
}
private static void authenticateAs(Authentication auth, User principal) {
when(auth.getPrincipal()).thenReturn(principal);
SecurityContextHolder.getContext().setAuthentication(auth);
}
@Test
void chargesComputedUnitsAsWebAutomation() {
User user = userWithTeam(3L, 7L);
authenticateAs(mock(Authentication.class), user);
PricingPolicy policy = policy();
when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy);
// 25 pages -> ceil(25/10)=3 page units; 5000 bytes -> 1 byte unit; max = 3.
biller.recordAutomationRun(List.of(new FileSize(25, 5000L)));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(3));
assertEquals(3L, ctx.getValue().ownerUserId());
assertEquals(7L, ctx.getValue().ownerTeamId());
assertEquals(JobSource.WEB, ctx.getValue().source());
assertEquals(ProcessType.AUTOMATION, ctx.getValue().processType());
assertEquals(BillingCategory.AUTOMATION, ctx.getValue().billingCategory());
}
@Test
void apiKeyAuthChargesAsApiSource() {
User user = userWithTeam(3L, 7L);
authenticateAs(mock(ApiKeyAuthenticationToken.class), user);
PricingPolicy policy = policy();
when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy);
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
assertEquals(JobSource.API, ctx.getValue().source());
}
@Test
void noChargeWithoutTeam() {
User user = mock(User.class);
when(user.getTeam()).thenReturn(null);
authenticateAs(mock(Authentication.class), user);
biller.recordAutomationRun(List.of(new FileSize(1, 10L)));
verify(jobChargeService, never())
.chargeStandalone(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt());
}
@Test
void noChargeForEmptyInputs() {
biller.recordAutomationRun(List.of());
verify(jobChargeService, never())
.chargeStandalone(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt());
}
}
@@ -5,11 +5,18 @@ import {
import { useCallback } from "react"; import { useCallback } from "react";
import { executeAutomationSequence } from "@app/utils/automationExecutor"; import { executeAutomationSequence } from "@app/utils/automationExecutor";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { useFileContext } from "@app/contexts/FileContext";
import { AutomateParameters } from "@app/types/automation"; import { AutomateParameters } from "@app/types/automation";
import {
meterAutomationRun,
type AutomationMeterInput,
} from "@app/services/automationMeter";
import { isStirlingFile } from "@app/types/fileContext";
export function useAutomateOperation() { export function useAutomateOperation() {
const { allTools } = useToolRegistry(); const { allTools } = useToolRegistry();
const toolRegistry = allTools; const toolRegistry = allTools;
const { selectors } = useFileContext();
const customProcessor = useCallback( const customProcessor = useCallback(
async (params: AutomateParameters, files: File[]) => { async (params: AutomateParameters, files: File[]) => {
@@ -21,10 +28,11 @@ export function useAutomateOperation() {
if (!params.automationConfig) { if (!params.automationConfig) {
throw new Error("No automation configuration provided"); throw new Error("No automation configuration provided");
} }
const automationConfig = params.automationConfig;
// Execute the automation sequence and return the final results // Execute the automation sequence and return the final results
const finalResults = await executeAutomationSequence( const finalResults = await executeAutomationSequence(
params.automationConfig, automationConfig,
files, files,
toolRegistry, toolRegistry,
(stepIndex: number, operationName: string) => { (stepIndex: number, operationName: string) => {
@@ -47,12 +55,35 @@ export function useAutomateOperation() {
console.log( console.log(
`✅ Automation completed, returning ${finalResults.length} files`, `✅ Automation completed, returning ${finalResults.length} files`,
); );
// Meter the completed run. Charged on the input set's doc-units (once
// per run) so an Automate workflow costs the same as the equivalent policy.
// Best-effort and post-success - never blocks the returned result.
try {
const inputs: AutomationMeterInput[] = files.map((file) => ({
pages: isStirlingFile(file)
? (selectors.getStirlingFileStub(file.fileId)?.processedFile
?.totalPages ?? 0)
: 0,
bytes: file.size ?? 0,
}));
meterAutomationRun({
automationName: automationConfig.name,
operations: automationConfig.operations.map(
(operation) => operation.operation,
),
inputs,
});
} catch (meterError) {
console.warn("Automation metering skipped:", meterError);
}
return { return {
files: finalResults, files: finalResults,
consumedAllInputs: true, consumedAllInputs: true,
}; };
}, },
[toolRegistry], [toolRegistry, selectors],
); );
return useToolOperation<AutomateParameters>( return useToolOperation<AutomateParameters>(
@@ -0,0 +1,18 @@
// Records a completed in-browser Automate run for billing/audit.
/** One input document's page count (0 for non-PDF / unknown) and byte size. */
export interface AutomationMeterInput {
pages: number;
bytes: number;
}
export interface AutomationMeterPayload {
automationName?: string;
operations?: string[];
inputs: AutomationMeterInput[];
}
/** Meter a completed Automate run. Fire-and-forget; never awaited, never throws. */
export function meterAutomationRun(_payload: AutomationMeterPayload): void {
// No billing layer in the core build.
}
@@ -0,0 +1,17 @@
// Meters an in-browser Automate run for billing/audit.
import apiClient from "@app/services/apiClient";
import { type AutomationMeterPayload } from "@core/services/automationMeter";
export {
type AutomationMeterInput,
type AutomationMeterPayload,
} from "@core/services/automationMeter";
export function meterAutomationRun(payload: AutomationMeterPayload): void {
void apiClient
.post(`/api/v1/automate/meter`, payload, { suppressErrorToast: true })
.catch(() => {
// Best-effort billing; the automation already succeeded in the browser.
});
}