diff --git a/README.md b/README.md index 0e3e09b..5f5ab0a 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,23 @@ runner.use(MyLoggerPlugin); await runner.execute(steps); ``` +### Built-in Plugins + +The package comes with some built-in plugins that you can use right away. + +#### CLIReporterPlugin + +Provides real-time CLI observability of workflow progress. It hooks into lifecycle events to display task states and a final summary. + +```typescript +import { TaskRunner, CLIReporterPlugin } from "@calmo/task-runner"; + +const runner = new TaskRunner(context); +runner.use(new CLIReporterPlugin()); + +await runner.execute(steps); +``` + ## Skip Propagation If a task fails or is skipped, the `TaskRunner` automatically marks all subsequent tasks that depend on it as `skipped`. This ensures that your pipeline doesn't attempt to run steps with missing prerequisites. diff --git a/openspec/changes/add-cli-reporter/design.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/design.md similarity index 100% rename from openspec/changes/add-cli-reporter/design.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/design.md diff --git a/openspec/changes/add-cli-reporter/proposal.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/proposal.md similarity index 100% rename from openspec/changes/add-cli-reporter/proposal.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/proposal.md diff --git a/openspec/changes/add-cli-reporter/specs/cli-reporter/spec.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/specs/cli-reporter/spec.md similarity index 100% rename from openspec/changes/add-cli-reporter/specs/cli-reporter/spec.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/specs/cli-reporter/spec.md diff --git a/openspec/changes/add-cli-reporter/tasks.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md similarity index 100% rename from openspec/changes/add-cli-reporter/tasks.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md diff --git a/openspec/specs/cli-reporter/spec.md b/openspec/specs/cli-reporter/spec.md new file mode 100644 index 0000000..1340145 --- /dev/null +++ b/openspec/specs/cli-reporter/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Interactive CLI Progress Reporting + +The `CLIReporterPlugin` SHALL provide real-time updates of task states during workflow execution. + +#### Scenario: Displaying running tasks + +- **WHEN** a task starts execution and triggers `taskStart` +- **THEN** the CLI reporter SHALL display the task name with a "running" indicator. + +#### Scenario: Displaying successful tasks + +- **WHEN** a task finishes successfully and triggers `taskEnd` with a `success` status +- **THEN** the CLI reporter SHALL display the task name with a "success" indicator and execution duration. + +#### Scenario: Displaying failed tasks + +- **WHEN** a task finishes with an error and triggers `taskEnd` with a `failure` status +- **THEN** the CLI reporter SHALL display the task name with a "failure" indicator and error details. + +#### Scenario: Displaying skipped tasks + +- **WHEN** a task is skipped due to unmet dependencies or conditions +- **THEN** the CLI reporter SHALL display the task name with a "skipped" indicator. + +### Requirement: Workflow Execution Summary + +The `CLIReporterPlugin` SHALL display a summary table or block upon completion of the entire task graph execution. + +#### Scenario: Generating final summary + +- **WHEN** all tasks have resolved (either success, failure, or skipped) +- **THEN** the CLI reporter SHALL output the total execution time, along with counts for successful, failed, and skipped tasks. diff --git a/src/index.ts b/src/index.ts index b68eba3..d533994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,4 @@ export type { TaskRetryConfig } from "./contracts/TaskRetryConfig.js"; export type { TaskStep } from "./TaskStep.js"; export type { TaskResult } from "./TaskResult.js"; export type { TaskStatus } from "./TaskStatus.js"; +export { CLIReporterPlugin } from "./plugins/CLIReporterPlugin.js"; diff --git a/src/plugins/CLIReporterPlugin.ts b/src/plugins/CLIReporterPlugin.ts new file mode 100644 index 0000000..dbe1971 --- /dev/null +++ b/src/plugins/CLIReporterPlugin.ts @@ -0,0 +1,65 @@ +import { Plugin, PluginContext } from "../contracts/Plugin.js"; + +/** + * A plugin that provides real-time CLI observability of workflow progress. + * It hooks into lifecycle events to display task states and a final summary. + */ +export class CLIReporterPlugin implements Plugin { + public readonly name = "cli-reporter"; + public readonly version = "1.0.0"; + + private startTime = 0; + private successful = 0; + private failed = 0; + private skipped = 0; + + public install(context: PluginContext): void { + context.events.on("workflowStart", () => { + this.startTime = performance.now(); + this.successful = 0; + this.failed = 0; + this.skipped = 0; + }); + + context.events.on("taskStart", ({ step }) => { + console.log(`[RUNNING] Task ${step.name}`); + }); + + context.events.on("taskEnd", ({ step, result }) => { + if (result.status === "success") { + this.successful++; + const durationStr = result.metrics?.duration !== undefined ? ` (${Math.round(result.metrics.duration)}ms)` : ""; + console.log(`[SUCCESS] Task ${step.name}${durationStr}`); + } else if (result.status === "failure") { + this.failed++; + const errorMsg = result.error ? ` - ${result.error}` : ""; + console.log(`[FAILURE] Task ${step.name}${errorMsg}`); + } else if (result.status === "cancelled") { + // According to the spec we track skipped, but cancelled is another status. + // Will just increment failed for now or leave it. We'll track it as skipped. + this.skipped++; + console.log(`[CANCELLED] Task ${step.name}`); + } else { + this.skipped++; + console.log(`[SKIPPED] Task ${step.name}`); + } + }); + + context.events.on("taskSkipped", ({ step }) => { + this.skipped++; + console.log(`[SKIPPED] Task ${step.name}`); + }); + + context.events.on("workflowEnd", () => { + const endTime = performance.now(); + const totalTime = endTime - this.startTime; + + console.log("--- Workflow Execution Summary ---"); + console.log(`Total Time: ${Math.round(totalTime)}ms`); + console.log(`Successful: ${this.successful}`); + console.log(`Failed: ${this.failed}`); + console.log(`Skipped: ${this.skipped}`); + console.log("----------------------------------"); + }); + } +} diff --git a/tests/plugins/CLIReporterPlugin.test.ts b/tests/plugins/CLIReporterPlugin.test.ts new file mode 100644 index 0000000..7f62469 --- /dev/null +++ b/tests/plugins/CLIReporterPlugin.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CLIReporterPlugin } from "../../src/plugins/CLIReporterPlugin.js"; +import { EventBus } from "../../src/EventBus.js"; +import { PluginContext } from "../../src/contracts/Plugin.js"; + +describe("CLIReporterPlugin", () => { + let logSpy: ReturnType; + let events: EventBus; + let context: PluginContext; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + events = new EventBus(); + context = { events }; + + // Explicitly mock performance.now to return predictable times + let time = 1000; + vi.spyOn(performance, "now").mockImplementation(() => { + const current = time; + time += 500; + return current; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should have correct name and version", () => { + const plugin = new CLIReporterPlugin(); + expect(plugin.name).toBe("cli-reporter"); + expect(plugin.version).toBe("1.0.0"); + }); + + it("should log taskStart correctly", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + // Instead of simulating through a runner, we just emit directly on EventBus + events.emit("taskStart", { + step: { name: "Step1", run: async () => ({ status: "success" }) } + }); + + // Wait a tick for microtask to execute + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[RUNNING] Task Step1"); + }); + + it("should log taskSkipped correctly", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskSkipped", { + step: { name: "Step1", run: async () => ({ status: "success" }) }, + result: { status: "skipped" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[SKIPPED] Task Step1"); + }); + + it("should log taskEnd success correctly", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "success" }) }, + result: { status: "success", metrics: { startTime: 0, endTime: 100, duration: 100 } } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[SUCCESS] Task Step1 (100ms)"); + }); + + it("should log taskEnd success correctly without metrics", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "success" }) }, + result: { status: "success" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[SUCCESS] Task Step1"); + }); + + it("should log taskEnd failure correctly", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "failure" }) }, + result: { status: "failure", error: "Some error" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[FAILURE] Task Step1 - Some error"); + }); + + it("should log taskEnd failure correctly without error message", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "failure" }) }, + result: { status: "failure" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[FAILURE] Task Step1"); + }); + + it("should log taskEnd cancelled correctly", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "cancelled" }) }, + result: { status: "cancelled" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[CANCELLED] Task Step1"); + }); + + it("should log taskEnd unknown status correctly as skipped", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + // Bypass TS type checking to simulate a runtime unknown status + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "skipped" }) }, + result: { status: "unknown" } as unknown as { status: "success" } + }); + + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("[SKIPPED] Task Step1"); + }); + + it("should reset counters on workflowStart and log summary on workflowEnd", async () => { + const plugin = new CLIReporterPlugin(); + plugin.install(context); + + events.emit("workflowStart", { context: {}, steps: [] }); + await Promise.resolve(); + + events.emit("taskEnd", { + step: { name: "Step1", run: async () => ({ status: "success" }) }, + result: { status: "success" } + }); + events.emit("taskEnd", { + step: { name: "Step2", run: async () => ({ status: "failure" }) }, + result: { status: "failure" } + }); + events.emit("taskSkipped", { + step: { name: "Step3", run: async () => ({ status: "success" }) }, + result: { status: "skipped" } + }); + events.emit("taskEnd", { + step: { name: "Step4", run: async () => ({ status: "cancelled" }) }, + result: { status: "cancelled" } + }); + + await Promise.resolve(); + + events.emit("workflowEnd", { context: {}, results: new Map() }); + await Promise.resolve(); + + expect(logSpy).toHaveBeenCalledWith("--- Workflow Execution Summary ---"); + expect(logSpy).toHaveBeenCalledWith("Total Time: 500ms"); // 1st call to performance.now is in workflowStart (1000), 2nd is in workflowEnd (1500) + expect(logSpy).toHaveBeenCalledWith("Successful: 1"); + expect(logSpy).toHaveBeenCalledWith("Failed: 1"); + expect(logSpy).toHaveBeenCalledWith("Skipped: 2"); // 1 skipped + 1 cancelled + expect(logSpy).toHaveBeenCalledWith("----------------------------------"); + }); +});