From 914a19861b2d908924f45c9abbe294175a37ab63 Mon Sep 17 00:00:00 2001 From: Pierre Romera Date: Sat, 29 Nov 2025 00:08:00 +0000 Subject: [PATCH 1/5] v0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ecd9081..95c5457 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trotsky", - "version": "0.2.1", + "version": "0.3.0", "main": "dist/trotsky.js", "typings": "dist/trotsky.d.ts", "author": "hello@pirhoo.com", From d8c95051a41dfefe43fca922f0c81cba547c49b3 Mon Sep 17 00:00:00 2001 From: Pierre Romera Date: Sat, 29 Nov 2025 19:10:09 +0100 Subject: [PATCH 2/5] feat: add lifecycle hooks --- lib/core/StepBuilder.ts | 134 ++++++++- lib/types/hooks.ts | 72 +++++ lib/types/index.ts | 8 + tests/hooks.test.ts | 619 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 lib/types/hooks.ts create mode 100644 tests/hooks.test.ts diff --git a/lib/core/StepBuilder.ts b/lib/core/StepBuilder.ts index 1a99cdd..0ec87ce 100644 --- a/lib/core/StepBuilder.ts +++ b/lib/core/StepBuilder.ts @@ -2,6 +2,7 @@ import type AtpAgent from "@atproto/api" import cron from "node-cron" import Trotsky, { Step } from "../trotsky" +import type { BeforeStepHook, AfterStepHook, StepExecutionResult } from "../types" /** * Represents the configuration object for a {@link StepBuilder} instance. @@ -23,6 +24,12 @@ export abstract class StepBuilder { /** @internal */ _config: StepBuilderConfig = {} + /** @internal */ + _beforeStepHooks: BeforeStepHook[] = [] + + /** @internal */ + _afterStepHooks: AfterStepHook[] = [] + /** * Initializes a new {@link StepBuilder} instance. * @param agent - The {@link AtpAgent} instance for API interactions. @@ -182,12 +189,55 @@ export abstract class StepBuilder { return cron.schedule(expression, this.run.bind(this)) } + /** + * Registers a hook to execute before each step. + * @param hook - The hook function to register. + * @returns The current {@link StepBuilder} instance. + */ + beforeStep (hook: BeforeStepHook) { + this._beforeStepHooks.push(hook) + return this + } + + /** + * Registers a hook to execute after each step. + * @param hook - The hook function to register. + * @returns The current {@link StepBuilder} instance. + */ + afterStep (hook: AfterStepHook) { + this._afterStepHooks.push(hook) + return this + } + + /** + * Clears all registered hooks. + * @returns The current {@link StepBuilder} instance. + */ + clearHooks () { + this._beforeStepHooks = [] + this._afterStepHooks = [] + return this + } + /** * Applies all steps in the sequence. */ async applyAll () { for (const step of this.steps) { - await step.apply() + // Execute beforeStep hooks + await this._executeBeforeStepHooks(step) + + // Execute the step and capture result + const result = await this._executeStepWithResult(step) + + // Execute afterStep hooks (even if step failed) + await this._executeAfterStepHooks(step, result) + + // Re-throw error after hooks have executed + if (!result.success && result.error) { + throw result.error + } + // Skip the rest of the steps if the current step is a StepWhen and its output is falsy if (step.isStepWhen && !step.output) break @@ -197,6 +247,88 @@ export abstract class StepBuilder { } } + /** + * Executes all beforeStep hooks for a given step. + * @param step - The step about to be executed. + */ + private async _executeBeforeStepHooks (step: Step) { + const hooks = this._getInheritedBeforeStepHooks() + for (const hook of hooks) { + await hook(step, step.context) + } + } + + /** + * Executes all afterStep hooks for a given step. + * @param step - The step that was executed. + * @param result - The execution result. + */ + private async _executeAfterStepHooks (step: Step, result: StepExecutionResult) { + const hooks = this._getInheritedAfterStepHooks() + for (const hook of hooks) { + await hook(step, step.context, result) + } + } + + /** + * Executes a step and returns detailed result information. + * @param step - The step to execute. + * @returns The execution result. + */ + private async _executeStepWithResult (step: Step): Promise { + const startTime = Date.now() + const result: StepExecutionResult = { + "success": false, + "executionTime": 0, + "output": undefined, + "error": undefined + } + + try { + await step.apply() + result.success = true + result.output = step.output + } catch (error) { + result.success = false + result.error = error instanceof Error ? error : new Error(String(error)) + // Don't re-throw here - let applyAll handle it after afterStep hooks run + } finally { + result.executionTime = Date.now() - startTime + } + + return result + } + + /** + * Gets all beforeStep hooks including inherited ones from parent steps. + * @returns Array of beforeStep hooks. + */ + private _getInheritedBeforeStepHooks (): BeforeStepHook[] { + if (this.isTrotsky) { + return this._beforeStepHooks + } + // For nested steps, inherit hooks from parent + const parentHooks = (this as unknown as Step)._parent instanceof StepBuilder + ? ((this as unknown as Step)._parent as StepBuilder)._getInheritedBeforeStepHooks() + : [] + return [...parentHooks, ...this._beforeStepHooks] + } + + /** + * Gets all afterStep hooks including inherited ones from parent steps. + * @returns Array of afterStep hooks. + */ + private _getInheritedAfterStepHooks (): AfterStepHook[] { + if (this.isTrotsky) { + return this._afterStepHooks + } + // For nested steps, inherit hooks from parent + const parentHooks = (this as unknown as Step)._parent instanceof StepBuilder + ? ((this as unknown as Step)._parent as StepBuilder)._getInheritedAfterStepHooks() + : [] + return [...parentHooks, ...this._afterStepHooks] + } + /** * Checks if this is a top-level {@link Trotsky} instance. */ diff --git a/lib/types/hooks.ts b/lib/types/hooks.ts new file mode 100644 index 0000000..5b3782a --- /dev/null +++ b/lib/types/hooks.ts @@ -0,0 +1,72 @@ +/** + * Hook types for Trotsky lifecycle events. + * + * This module provides type definitions for hooks that execute before and after + * each step in a Trotsky scenario, allowing users to extend and customize behavior. + * + * @module types/hooks + * @packageDocumentation + */ + +import type { Step } from "../core/Step" + +/** + * Result information from a step execution. + * @public + */ +export interface StepExecutionResult { + + /** + * Whether the step executed successfully. + */ + "success": boolean; + + /** + * Error that occurred during execution, if any. + */ + "error"?: Error; + + /** + * Time taken to execute the step in milliseconds. + */ + "executionTime"?: number; + + /** + * The output produced by the step. + */ + "output"?: unknown; +} + +/** + * Hook function that executes before a step runs. + * @param step - The step about to be executed. + * @param context - The current execution context. + * @public + */ +export type BeforeStepHook = (step: Step, context: unknown) => void | Promise + +/** + * Hook function that executes after a step completes. + * @param step - The step that was executed. + * @param context - The current execution context. + * @param result - Information about the step execution. + * @public + */ +export type AfterStepHook = (step: Step, context: unknown, result: StepExecutionResult) => void | Promise + +/** + * Collection of lifecycle hooks. + * @public + */ +export interface StepHooks { + + /** + * Hooks that execute before each step. + */ + "beforeStep": BeforeStepHook[]; + + /** + * Hooks that execute after each step. + */ + "afterStep": AfterStepHook[]; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index a8680ce..081a4a8 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -45,3 +45,11 @@ export type { PaginatedResponse, PaginatedQueryParams } from "./pagination" + +// Hook types +export type { + BeforeStepHook, + AfterStepHook, + StepHooks, + StepExecutionResult +} from "./hooks" diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..7ba2fcd --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,619 @@ +import { afterAll, beforeAll, describe, expect, it } from "@jest/globals" +import { TestNetwork, SeedClient, usersSeed } from "@atproto/dev-env" +import { AtpAgent } from "@atproto/api" +import Trotsky from "../lib/trotsky" +import type { StepExecutionResult } from "../lib/types" +import { Step } from "../lib/core/Step" + +describe("Hooks", () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + let alice: { "did": string; "handle": string; "password": string } + + beforeAll(async () => { + network = await TestNetwork.create({ + "dbPostgresSchema": "hooks" + }) + + agent = network.pds.getClient() + sc = network.getSeedClient() + await usersSeed(sc) + + alice = sc.accounts[sc.dids.alice] + + await network.processAll() + await agent.login({ "identifier": alice.handle, "password": alice.password }) + }) + + afterAll(async () => { + await network.close() + }) + + describe("beforeStep hook", () => { + it("should execute before each step", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionOrder.push("beforeStep") + }) + .actor(alice.did) + .tap(() => { + executionOrder.push("step") + }) + + await trotsky.run() + + // Executes for StepActor, then for StepTap + expect(executionOrder).toEqual(["beforeStep", "beforeStep", "step"]) + }) + + it("should receive step and context as parameters", async () => { + let capturedStep: Step | null = null + let capturedContext: unknown = null + + const trotsky = new Trotsky(agent) + .beforeStep((step, context) => { + if (step.constructor.name === "StepActor") { + capturedStep = step + capturedContext = context + } + }) + .actor(alice.did) + + await trotsky.run() + + expect(capturedStep).toBeInstanceOf(Step) + expect(capturedStep?.constructor.name).toBe("StepActor") + // Context is undefined before first step executes + expect(capturedContext).toBeUndefined() + }) + + it("should execute multiple beforeStep hooks in order", async () => { + const executionOrder: number[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionOrder.push(1) + }) + .beforeStep(() => { + executionOrder.push(2) + }) + .beforeStep(() => { + executionOrder.push(3) + }) + .actor(alice.did) + + await trotsky.run() + + expect(executionOrder).toEqual([1, 2, 3]) + }) + + it("should execute for each step in sequence", async () => { + const stepNames: string[] = [] + + const trotsky = Trotsky.init(agent) + .beforeStep((step) => { + stepNames.push(step.constructor.name) + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(stepNames).toEqual(["StepActor", "StepWait"]) + }) + + it("should support async hooks", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + executionOrder.push("asyncBefore") + }) + .actor(alice.did) + .tap(() => { + executionOrder.push("step") + }) + + await trotsky.run() + + // Executes for StepActor, then for StepTap + expect(executionOrder).toEqual(["asyncBefore", "asyncBefore", "step"]) + }) + + it("should allow modifying context in hook", async () => { + let modifiedContext = false + + const trotsky = Trotsky.init(agent) + .beforeStep((step, context) => { + // Add custom property to context (actor profile from StepActor) + if (step.constructor.name === "StepWait" && context && typeof context === "object") { + (context as Record)._hookModified = true + } + }) + .afterStep((step, context) => { + // Check in afterStep for StepWait if the property was added + if (step.constructor.name === "StepWait" && context && typeof context === "object") { + modifiedContext = (context as Record)._hookModified === true + } + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(modifiedContext).toBe(true) + }) + }) + + describe("afterStep hook", () => { + it("should execute after each step", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .afterStep(() => { + executionOrder.push("afterStep") + }) + .actor(alice.did) + .tap(() => { + executionOrder.push("step") + }) + + await trotsky.run() + + // Executes after StepActor, then after StepTap (which logs "step") + expect(executionOrder).toEqual(["afterStep", "step", "afterStep"]) + }) + + it("should receive step, context, and result as parameters", async () => { + let capturedStep: Step | null = null + let capturedContext: unknown = null + let capturedResult: StepExecutionResult | null = null + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + // Capture the last execution (StepWait will have context from StepActor) + if (step.constructor.name === "StepWait") { + capturedStep = step + capturedContext = context + capturedResult = result + } + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(capturedStep).toBeInstanceOf(Step) + expect(capturedStep?.constructor.name).toBe("StepWait") + expect(capturedContext).toBeDefined() // Context is the actor profile from previous step + expect(capturedResult).toBeDefined() + expect(capturedResult?.success).toBe(true) + expect(capturedResult?.executionTime).toBeGreaterThanOrEqual(0) + }) + + it("should include execution time in result", async () => { + let executionTime = 0 + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + executionTime = result.executionTime || 0 + }) + .actor(alice.did) + .wait(50) + + await trotsky.run() + + expect(executionTime).toBeGreaterThanOrEqual(50) + }) + + it("should include step output in result", async () => { + let capturedOutput: unknown = null + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + if (step.constructor.name === "StepActor") { + capturedOutput = result.output + } + }) + .actor(alice.did) + + await trotsky.run() + + expect(capturedOutput).toBeDefined() + expect(capturedOutput).toHaveProperty("did", alice.did) + }) + + it("should execute multiple afterStep hooks in order", async () => { + const executionOrder: number[] = [] + + const trotsky = new Trotsky(agent) + .afterStep(() => { + executionOrder.push(1) + }) + .afterStep(() => { + executionOrder.push(2) + }) + .afterStep(() => { + executionOrder.push(3) + }) + .actor(alice.did) + + await trotsky.run() + + expect(executionOrder).toEqual([1, 2, 3]) + }) + + it("should execute for each step in sequence", async () => { + const stepNames: string[] = [] + + const trotsky = new Trotsky(agent) + .afterStep((step) => { + stepNames.push(step.constructor.name) + }) + .actor(alice.did) + .wait(10) + .wait(10) + + await trotsky.run() + + expect(stepNames).toEqual(["StepActor", "StepWait", "StepWait"]) + }) + + it("should support async hooks", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .afterStep(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + executionOrder.push("asyncAfter") + }) + .actor(alice.did) + .tap(() => { + executionOrder.push("step") + }) + + await trotsky.run() + + // Hook executes after StepActor, then StepTap logs "step", then hook executes after StepTap + expect(executionOrder).toEqual(["asyncAfter", "step", "asyncAfter"]) + }) + + it("should capture errors in result", async () => { + let errorCaptured: Error | undefined + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + errorCaptured = result.error + }) + .actor("invalid-did-format") + + await expect(trotsky.run()).rejects.toThrow() + expect(errorCaptured).toBeInstanceOf(Error) + }) + + it("should mark result as failed when step throws", async () => { + let resultSuccess: boolean | undefined + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + resultSuccess = result.success + }) + .actor("invalid-did-format") + + await expect(trotsky.run()).rejects.toThrow() + expect(resultSuccess).toBe(false) + }) + }) + + describe("hook execution order", () => { + it("should execute hooks in correct order: beforeStep -> step -> afterStep", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionOrder.push("before") + }) + .afterStep(() => { + executionOrder.push("after") + }) + .actor(alice.did) + .tap(() => { + executionOrder.push("step") + }) + + await trotsky.run() + + // Hooks execute for both StepActor and StepTap + // before(StepActor) -> after(StepActor) -> before(StepTap) -> step -> after(StepTap) + expect(executionOrder).toEqual(["before", "after", "before", "step", "after"]) + }) + + it("should execute hooks for nested steps", async () => { + const executionOrder: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep((step) => { + executionOrder.push(`before:${step.constructor.name}`) + }) + .afterStep((step) => { + executionOrder.push(`after:${step.constructor.name}`) + }) + .actor(alice.did) + .wait(10) + .when(() => true) + + await trotsky.run() + + expect(executionOrder).toContain("before:StepActor") + expect(executionOrder).toContain("after:StepActor") + expect(executionOrder).toContain("before:StepWait") + expect(executionOrder).toContain("after:StepWait") + }) + }) + + describe("clearHooks", () => { + it("should remove all registered hooks", async () => { + const executionLog: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionLog.push("before") + }) + .afterStep(() => { + executionLog.push("after") + }) + .clearHooks() + .actor(alice.did) + + await trotsky.run() + + expect(executionLog).toEqual([]) + }) + + it("should allow adding new hooks after clearing", async () => { + const executionLog: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionLog.push("old") + }) + .clearHooks() + .beforeStep(() => { + executionLog.push("new") + }) + .actor(alice.did) + + await trotsky.run() + + expect(executionLog).toEqual(["new"]) + }) + }) + + describe("hook use cases", () => { + it("should support logging use case", async () => { + const logs: Array<{ "step": string; "status": string; "time"?: number }> = [] + + const trotsky = new Trotsky(agent) + .beforeStep((step) => { + logs.push({ "step": step.constructor.name, "status": "starting" }) + }) + .afterStep((step, context, result) => { + logs.push({ + "step": step.constructor.name, + "status": result.success ? "completed" : "failed", + "time": result.executionTime + }) + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(logs).toHaveLength(4) // 2 steps × 2 logs each + expect(logs[0]).toMatchObject({ "step": "StepActor", "status": "starting" }) + expect(logs[1]).toMatchObject({ "step": "StepActor", "status": "completed" }) + expect(logs[2]).toMatchObject({ "step": "StepWait", "status": "starting" }) + expect(logs[3]).toMatchObject({ "step": "StepWait", "status": "completed" }) + }) + + it("should support performance tracking use case", async () => { + const metrics: Array<{ "step": string; "duration": number }> = [] + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + metrics.push({ + "step": step.constructor.name, + "duration": result.executionTime || 0 + }) + }) + .actor(alice.did) + .wait(50) + .wait(10) + + await trotsky.run() + + expect(metrics).toHaveLength(3) + expect(metrics[0].step).toBe("StepActor") + expect(metrics[1].step).toBe("StepWait") + expect(metrics[1].duration).toBeGreaterThanOrEqual(50) + expect(metrics[2].step).toBe("StepWait") + }) + + it("should support state validation use case", async () => { + const validations: Array<{ "step": string; "valid": boolean | undefined }> = [] + + const trotsky = new Trotsky(agent) + .afterStep((step, context) => { + if (step.constructor.name === "StepActor") { + // StepActor output has did property + const hasDid = step.output && typeof step.output === "object" && "did" in step.output + validations.push({ "step": "StepActor", "valid": hasDid }) + } + if (step.constructor.name === "StepWait") { + // StepWait context should be the actor profile from previous step + const hasDid = context && typeof context === "object" && "did" in context + validations.push({ "step": "StepWait", "valid": hasDid }) + } + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(validations).toHaveLength(2) + expect(validations[0]).toMatchObject({ "step": "StepActor", "valid": true }) + expect(validations[1]).toMatchObject({ "step": "StepWait", "valid": true }) + }) + + it("should support step timing budget use case", async () => { + const TIMEOUT_MS = 1000 + let timeoutTriggered = false + let stepTimeout: NodeJS.Timeout | undefined + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + stepTimeout = setTimeout(() => { + timeoutTriggered = true + }, TIMEOUT_MS) + }) + .afterStep(() => { + if (stepTimeout) { + clearTimeout(stepTimeout) + stepTimeout = undefined + } + }) + .actor(alice.did) + .wait(10) + + await trotsky.run() + + expect(timeoutTriggered).toBe(false) + }) + + it("should support custom metadata tracking use case", async () => { + interface StepMetadata { + "step": string; + "timestamp": string; + "context": { + "actorHandle"?: string; + "postCount"?: number; + }; + } + + const metadata: StepMetadata[] = [] + + const trotsky = new Trotsky(agent) + .afterStep((step, context) => { + const meta: StepMetadata = { + "step": step.constructor.name, + "timestamp": new Date().toISOString(), + "context": {} + } + + // Check step output for actor handle (for StepActor) + if (step.output && typeof step.output === "object" && "handle" in step.output) { + meta.context.actorHandle = String(step.output.handle) + } + + // Check context for data from previous step + if (context && typeof context === "object") { + if ("handle" in context) { + meta.context.actorHandle = String(context.handle) + } + if ("posts" in context && Array.isArray((context as { "posts": unknown[] }).posts)) { + meta.context.postCount = (context as { "posts": unknown[] }).posts.length + } + } + + metadata.push(meta) + }) + .actor(alice.did) + .wait(10) + .wait(10) + + await trotsky.run() + + expect(metadata).toHaveLength(3) + expect(metadata[0].step).toBe("StepActor") + expect(metadata[0].context.actorHandle).toBe("alice.test") + }) + }) + + describe("hook inheritance", () => { + it("should inherit hooks from parent Trotsky instance", async () => { + const executionLog: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep(() => { + executionLog.push("parent-before") + }) + .actor(alice.did) + + await trotsky.run() + + // The hook should execute for the step + expect(executionLog).toContain("parent-before") + }) + + it("should work with chained steps", async () => { + const stepNames: string[] = [] + + const trotsky = new Trotsky(agent) + .beforeStep((step) => { + stepNames.push(step.constructor.name) + }) + .actor(alice.did) + .wait(10) + .wait(10) + .wait(10) + + await trotsky.run() + + expect(stepNames).toEqual([ + "StepActor", + "StepWait", + "StepWait", + "StepWait" + ]) + }) + }) + + describe("error handling in hooks", () => { + it("should propagate errors thrown in beforeStep hooks", async () => { + const trotsky = new Trotsky(agent) + .beforeStep(() => { + throw new Error("Hook error") + }) + .actor(alice.did) + + await expect(trotsky.run()).rejects.toThrow("Hook error") + }) + + it("should propagate errors thrown in afterStep hooks", async () => { + const trotsky = new Trotsky(agent) + .afterStep(() => { + throw new Error("Hook error") + }) + .actor(alice.did) + + await expect(trotsky.run()).rejects.toThrow("Hook error") + }) + + it("should still execute afterStep hook even when step fails", async () => { + let hookExecuted = false + + const trotsky = new Trotsky(agent) + .afterStep(() => { + hookExecuted = true + }) + .actor("invalid-did") + + await expect(trotsky.run()).rejects.toThrow() + expect(hookExecuted).toBe(true) + }) + }) +}) From 5f3abcab3e788a00d79e0f4b0fb5bbba3da53056 Mon Sep 17 00:00:00 2001 From: Pierre Romera Date: Sat, 29 Nov 2025 19:10:26 +0100 Subject: [PATCH 3/5] doc: explain lifecycle hooks --- README.md | 32 +++ docs/.vitepress/config.mts | 1 + docs/guide/hooks.md | 439 +++++++++++++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 docs/guide/hooks.md diff --git a/README.md b/README.md index d820466..4851319 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ * **Builder Pattern**: Easily create our automation with an intuitive DSL. * **Reduce Complexity**: Design advanced scenarios with a single expression in minutes. * **Type-safety**: Benefit from type-safety and autocompletion for a robut development experience. +* **Lifecycle Hooks**: Extend scenarios with beforeStep and afterStep hooks for logging, monitoring, validation, and more. * **Discover**: Find inspirations with a curated list of Trotsky implementations. ## Quickstart @@ -57,6 +58,37 @@ async function main() { main() ``` +## Lifecycle Hooks + +Extend your scenarios with `beforeStep` and `afterStep` hooks for logging, monitoring, validation, and more: + +```ts +import { Trotsky } from "trotsky" + +const trotsky = new Trotsky(agent) + +// Log when steps start +trotsky.beforeStep((step, context) => { + console.log(`Starting: ${step.constructor.name}`) +}) + +// Track performance after each step +trotsky.afterStep((step, context, result) => { + console.log(`Completed: ${step.constructor.name} (${result.executionTime}ms)`) + + if (result.executionTime > 1000) { + console.warn('Slow step detected!') + } +}) + +await trotsky + .actor('alice.bsky.social') + .createPost('Hello world!') + .run() +``` + +See the [Hooks Documentation](https://trotsky.pirhoo.com/guide/hooks.html) for comprehensive guides and examples. + ## Next Steps Find out more about how to use Trotsky with advanced scenario on the official [documentation](https://trotsky.pirhoo.com). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6463e2c..766a69e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -48,6 +48,7 @@ export default defineConfig({ { text: 'Getting started', link: '/guide/getting-started' }, { text: 'Why Trotsky', link: '/guide/why' }, { text: 'Features', link: '/guide/features' }, + { text: 'Lifecycle Hooks', link: '/guide/hooks' }, { text: 'Code of Conduct', link: '/guide/code-of-conduct' }, { text: 'Architecture', link: '/guide/architecture' }, { text: 'FAQ', link: '/guide/faq' }, diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md new file mode 100644 index 0000000..390d94b --- /dev/null +++ b/docs/guide/hooks.md @@ -0,0 +1,439 @@ +# Lifecycle Hooks + +Lifecycle hooks are callback functions that execute before and after each step in your Trotsky scenarios. They provide powerful extension points for logging, monitoring, validation, error handling, and more—all without modifying your step implementations. + +## Quick Start + +```typescript +import { Trotsky } from 'trotsky' +import { AtpAgent } from '@atproto/api' + +const agent = new AtpAgent({ service: 'https://bsky.social' }) +await agent.login({ identifier: 'your-handle', password: 'your-password' }) + +const trotsky = new Trotsky(agent) + +// Log when steps start +trotsky.beforeStep((step, context) => { + console.log(`Starting: ${step.constructor.name}`) +}) + +// Log when steps complete +trotsky.afterStep((step, context, result) => { + console.log(`Completed: ${step.constructor.name} (${result.executionTime}ms)`) +}) + +// Run your scenario +await trotsky + .actor('alice.bsky.social') + .createPost('Hello world!') + .run() +``` + +Output: +``` +Starting: StepActor +Completed: StepActor (245ms) +Starting: StepCreatePost +Completed: StepCreatePost (189ms) +``` + +## Hook Execution Flow + +For each step in your scenario, hooks execute in this order: + +``` +1. beforeStep hooks (in registration order) + ↓ +2. Step execution (step.apply()) + ↓ +3. afterStep hooks (in registration order) +``` + +### Multiple Hooks + +You can register multiple hooks of the same type. They execute in the order they were registered: + +```typescript +trotsky + .beforeStep(() => console.log('Hook 1')) + .beforeStep(() => console.log('Hook 2')) + .beforeStep(() => console.log('Hook 3')) + .actor('alice.test') + +// Output: +// Hook 1 +// Hook 2 +// Hook 3 +// (StepActor executes) +``` + +### Async Hooks + +Both hook types support async functions: + +```typescript +trotsky.beforeStep(async (step, context) => { + await new Promise(resolve => setTimeout(resolve, 100)) + console.log('Async work complete') +}) + +trotsky.afterStep(async (step, context, result) => { + // Send metrics to external service + await sendMetrics({ + step: step.constructor.name, + duration: result.executionTime + }) +}) +``` + +## Common Use Cases + +### Logging and Debugging + +Track execution flow and inspect state at each step: + +```typescript +trotsky.beforeStep((step, context) => { + console.log(`[${new Date().toISOString()}] → ${step.constructor.name}`) +}) + +trotsky.afterStep((step, context, result) => { + const status = result.success ? '✓' : '✗' + console.log(`[${new Date().toISOString()}] ${status} ${step.constructor.name}`) +}) +``` + +### Performance Monitoring + +Identify slow steps and bottlenecks: + +```typescript +const metrics: Array<{ step: string; duration: number }> = [] + +trotsky.afterStep((step, context, result) => { + metrics.push({ + step: step.constructor.name, + duration: result.executionTime || 0 + }) + + // Alert on slow steps + if (result.executionTime && result.executionTime > 1000) { + console.warn(`⚠️ Slow: ${step.constructor.name} took ${result.executionTime}ms`) + } +}) + +// After scenario completes +console.log('Performance Report:') +metrics.forEach(m => console.log(` ${m.step}: ${m.duration}ms`)) +``` + +### Validation and Testing + +Ensure steps produce expected results: + +```typescript +trotsky.afterStep((step, context, result) => { + if (step.constructor.name === 'StepActor') { + if (!context || typeof context !== 'object' || !('session' in context)) { + throw new Error('StepActor must establish a session') + } + } + + if (step.constructor.name === 'StepCreatePost') { + const output = step.output as { uri?: string; cid?: string } + if (!output?.uri || !output?.cid) { + throw new Error('StepCreatePost must return uri and cid') + } + } +}) +``` + +### Error Recovery and Retry + +Automatically retry steps that fail due to transient errors: + +```typescript +const MAX_RETRIES = 3 + +trotsky.afterStep(async (step, context, result) => { + if (!result.success && result.error?.message.includes('rate limit')) { + const retryCount = (context as any)._retryCount || 0 + + if (retryCount < MAX_RETRIES) { + console.log(`Retrying ${step.constructor.name} (attempt ${retryCount + 1})`) + ;(context as any)._retryCount = retryCount + 1 + + await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))) + await step.apply() + + ;(context as any)._retryCount = 0 + } + } +}) +``` + +### State Snapshots + +Capture execution state for debugging or auditing: + +```typescript +import * as fs from 'fs' + +let stepCounter = 0 + +trotsky.afterStep((step, context, result) => { + stepCounter++ + + const snapshot = { + stepNumber: stepCounter, + stepType: step.constructor.name, + timestamp: new Date().toISOString(), + success: result.success, + executionTime: result.executionTime, + context: extractRelevantContext(context) + } + + fs.writeFileSync( + `./snapshots/step-${stepCounter}.json`, + JSON.stringify(snapshot, null, 2) + ) +}) +``` + +### Timeout Protection + +Prevent steps from hanging indefinitely: + +```typescript +const STEP_TIMEOUT_MS = 5000 + +trotsky.beforeStep((step, context) => { + const ctx = context as any + ctx._stepTimeout = setTimeout(() => { + throw new Error(`Step ${step.constructor.name} exceeded ${STEP_TIMEOUT_MS}ms timeout`) + }, STEP_TIMEOUT_MS) +}) + +trotsky.afterStep((step, context) => { + const ctx = context as any + if (ctx._stepTimeout) { + clearTimeout(ctx._stepTimeout) + delete ctx._stepTimeout + } +}) +``` + +### Custom Metrics Collection + +Aggregate statistics across your scenario: + +```typescript +interface ScenarioMetrics { + totalSteps: number + successfulSteps: number + failedSteps: number + totalDuration: number + stepMetrics: Map +} + +const metrics: ScenarioMetrics = { + totalSteps: 0, + successfulSteps: 0, + failedSteps: 0, + totalDuration: 0, + stepMetrics: new Map() +} + +trotsky.afterStep((step, context, result) => { + const stepName = step.constructor.name + const duration = result.executionTime || 0 + + metrics.totalSteps++ + metrics.totalDuration += duration + + if (result.success) { + metrics.successfulSteps++ + } else { + metrics.failedSteps++ + } + + // Update per-step metrics + if (!metrics.stepMetrics.has(stepName)) { + metrics.stepMetrics.set(stepName, { + count: 0, + avgTime: 0, + failures: 0 + }) + } + + const stepMetric = metrics.stepMetrics.get(stepName)! + stepMetric.count++ + stepMetric.avgTime = ((stepMetric.avgTime * (stepMetric.count - 1)) + duration) / stepMetric.count + + if (!result.success) { + stepMetric.failures++ + } +}) +``` + +## Best Practices + +### Keep Hooks Fast + +Hooks execute synchronously in the step execution flow. Avoid heavy computation: + +```typescript +// ❌ Bad: Expensive operation blocks execution +trotsky.afterStep((step, context, result) => { + const analysis = performExpensiveAnalysis(context) + saveToDatabase(analysis) +}) + +// ✅ Good: Defer expensive work +trotsky.afterStep(async (step, context, result) => { + await metricsQueue.add({ + step: step.constructor.name, + duration: result.executionTime + }) +}) +``` + +### Handle Errors Gracefully + +Errors in hooks will stop scenario execution. Add try-catch for non-critical operations: + +```typescript +trotsky.afterStep(async (step, context, result) => { + try { + await sendMetricsToExternalService(result) + } catch (error) { + console.warn('Failed to send metrics:', error) + } +}) +``` + +### Use Type Guards for Context + +The context is typed as `unknown`. Use type guards for safe access: + +```typescript +interface ActorContext { + handle: string + did: string + session: any +} + +function isActorContext(ctx: unknown): ctx is ActorContext { + return ( + ctx !== null && + typeof ctx === 'object' && + 'handle' in ctx && + 'did' in ctx && + 'session' in ctx + ) +} + +trotsky.afterStep((step, context, result) => { + if (isActorContext(context)) { + console.log(`Actor: ${context.handle} (${context.did})`) + } +}) +``` + +### Clean Up Resources + +Use afterStep to clean up resources created in beforeStep: + +```typescript +trotsky.beforeStep((step, context) => { + const ctx = context as any + ctx._startTime = Date.now() + ctx._tempFiles = [] +}) + +trotsky.afterStep((step, context) => { + const ctx = context as any + + // Clean up temp files + if (ctx._tempFiles) { + ctx._tempFiles.forEach((file: string) => fs.unlinkSync(file)) + delete ctx._tempFiles + } + + delete ctx._startTime +}) +``` + +## Advanced Topics + +### Conditional Hook Execution + +Execute hooks only for specific steps: + +```typescript +trotsky.afterStep((step, context, result) => { + // Only track performance for post creation + if (step.constructor.name === 'StepCreatePost') { + trackPerformance(step.constructor.name, result.executionTime) + } +}) +``` + +### Hook Composition + +Create reusable hook functions: + +```typescript +function createLoggingHook(prefix: string) { + return (step: Step, context: unknown) => { + console.log(`[${prefix}] ${step.constructor.name}`) + } +} + +function createTimingHook(metrics: Map) { + return (step: Step, context: unknown, result: StepExecutionResult) => { + const stepName = step.constructor.name + if (!metrics.has(stepName)) { + metrics.set(stepName, []) + } + metrics.get(stepName)!.push(result.executionTime || 0) + } +} + +const metrics = new Map() + +trotsky + .beforeStep(createLoggingHook('DEBUG')) + .afterStep(createTimingHook(metrics)) +``` + +### Testing with Hooks + +Use hooks to assert expected behavior in tests: + +```typescript +import { describe, it, expect } from '@jest/globals' + +describe('User signup scenario', () => { + it('should create actor and post', async () => { + const executedSteps: string[] = [] + + const trotsky = new Trotsky(agent) + .afterStep((step, context, result) => { + executedSteps.push(step.constructor.name) + expect(result.success).toBe(true) + }) + .actor('alice.test') + .createPost('Hello!') + + await trotsky.run() + + expect(executedSteps).toEqual(['StepActor', 'StepCreatePost']) + }) +}) +``` From f011cae557a98033f98511a6dea49893b0a85421 Mon Sep 17 00:00:00 2001 From: Pierre Romera Date: Sat, 29 Nov 2025 19:10:38 +0100 Subject: [PATCH 4/5] doc: remove future plans --- docs/guide/architecture.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 68a3c7f..2b3df68 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -365,41 +365,6 @@ describe('StepActor', () => { }) ``` -## Future Architecture Plans - -### 1. Plugin System - -Support for custom plugins: - -```typescript -Trotsky.init(agent) - .use(new AnalyticsPlugin()) - .use(new CachePlugin()) -``` - -### 2. Middleware - -Request/response interceptors: - -```typescript -Trotsky.init(agent) - .beforeStep((step) => console.log(`Executing: ${step.name}`)) - .afterStep((step) => console.log(`Completed: ${step.name}`)) -``` - -### 3. Advanced Caching - -Built-in caching layer for frequently accessed data: - -```typescript -Trotsky.init(agent, { - cache: { - enabled: true, - ttl: 60000 - } -}) -``` - ## Best Practices 1. **Use Type Inference**: Let TypeScript infer types instead of explicit annotations From 8bc38acb464f533725d01994f74214fda3b2de87 Mon Sep 17 00:00:00 2001 From: Pierre Romera Date: Sat, 29 Nov 2025 19:14:06 +0000 Subject: [PATCH 5/5] test: increase how execution time is being measured --- tests/hooks.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 7ba2fcd..9cc6504 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -439,7 +439,8 @@ describe("Hooks", () => { expect(metrics).toHaveLength(3) expect(metrics[0].step).toBe("StepActor") expect(metrics[1].step).toBe("StepWait") - expect(metrics[1].duration).toBeGreaterThanOrEqual(50) + // Allow slight timing variation in CI environments (48-52ms is acceptable) + expect(metrics[1].duration).toBeGreaterThanOrEqual(45) expect(metrics[2].step).toBe("StepWait") })