Multi-trigger plugin (cron + event) + onError minimal pattern
multi-trigger (cron + event)
Mirrors runner/src/plugins/11-exampleMultiTrigger
What this teaches
- A plugin can register MULTIPLE triggers — same decide() runs for each
- Cron trigger does periodic health checks
- Event trigger reacts to upstream notifications (pg_notify topics from the indexer pipeline) — wired by the runtime via scheduler.dispatchEvent
- The plugin doesn't see which trigger fired; it acts on current state
- onError (minimal): log every failure with discriminated phase (client vs on-chain). No state, no audit — just visibility. This is the simplest possible error handler.
New vs exampleFailureAware
- 2 triggers driving the same logic
- onError dedicated to failure logging (no branching inside onExecuted)
import type { StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { minutes, seconds } from "@automark/sdk/duration";
const MIN_TICK_INTERVAL_MS = seconds(30); // dedupe overlapping triggers
export default function createExampleMultiTrigger(): StrategyPlugin {
const vaultId = process.env.VAULT_ID;
if (!vaultId) throw new Error("exampleMultiTrigger: VAULT_ID not set");
return {
name: "exampleMultiTrigger",
vaultId,
triggers: [
{ kind: "cron", everySeconds: 3600 }, // periodic safety net
{ kind: "event", topic: "deposit_processed" }, // react to new deposits
],
async decide(ctx) {
if (ctx.vault.isFrozen) return [{ kind: "noop", reason: "frozen" }];
// Idempotency: don't double-act if cron + event fire close together
const lastTickMs = (await ctx.state.get<number>("lastTickMs")) ?? 0;
if (ctx.now - lastTickMs < MIN_TICK_INTERVAL_MS) {
return [{ kind: "noop", reason: "tick dedupe" }];
}
await ctx.state.set("lastTickMs", ctx.now);
// Deploy idle capital — react both to time passing AND fresh deposits
const { quoteBalance, exposureHeadroom } = ctx.vault;
const deployable = quoteBalance < exposureHeadroom ? quoteBalance : exposureHeadroom;
if (deployable === 0n) {
return [{ kind: "noop", reason: "nothing to deploy" }];
}
const half = deployable / 2n;
const btc = await Market.find({
asset: "BTC",
expiryAfterMs: ctx.now + minutes(5),
client: ctx.suiClient,
});
const p = await btc.price();
return [
{ kind: "vault.pmDeposit", params: { amount: half } },
{
kind: "vault.mintBinary",
params: {
marketId: btc.id,
strike: btc.strikeAbove(p.forwardRaw, { pctBps: 250 }),
isUp: true,
quantity: half,
},
},
];
},
// Minimal error handler — just visibility. The phase discriminator lets
// ops differentiate transient RPC issues from real on-chain rejections.
async onError(ctx, err) {
ctx.logger.warn("execution failed", {
phase: err.phase,
digest: err.digest,
error: err.error,
dryRun: err.dryRun,
});
},
};
}
// insights
// Right at the start of the code, the triggers for running decide() are set up via a cron and via an external event
// Now, besides being fired every 3600 as in the example, the code also fires decide() whenever the "" event happens.
Environment variables
- VAULT_ID