Tasks

Tasks are operational entrypoints: backfills, repairs, imports, exports, and release maintenance that an operator runs on purpose. A task pairs a stable name with a validated input schema and a handler that receives the same app-owned context as the rest of the application, so operational work goes through ports, audit, and logging instead of one-off scripts.

Use a job when the app decides to run work in the background, a schedule when time triggers the work, and a task when a person, CI job, or release pipeline decides when it runs. See the background work overview for choosing between primitives.

Define a task

Create the app-bound builder once in lib/tasks.ts:

// lib/tasks.ts
import { createTasks } from "@beignet/core/tasks";
import type { AppContext } from "@/app-context";

export const { defineTask } = createTasks<AppContext>();

Then define feature-owned tasks under features/<feature>/tasks/:

// features/issues/tasks/backfill-search.ts
import { z } from "zod";
import { defineTask } from "@/lib/tasks";

export const BackfillSearchInputSchema = z.object({
  dryRun: z.boolean().default(true),
});

export const backfillSearchTask = defineTask("issues.backfill-search", {
  input: BackfillSearchInputSchema,
  description: "Backfill the issues search index.",
  async handle({ input, ctx }) {
    ctx.ports.logger.info("Task handled", {
      taskName: "issues.backfill-search",
      dryRun: input.dryRun,
    });

    return { dryRun: input.dryRun };
  },
});

Task handlers should call use cases and ports rather than reaching into infra directly. Inputs are parsed with the task's schema before the handler runs, so a typo'd flag fails with a TaskValidationError instead of mutating data.

The generator creates the task file, the lib/tasks.ts builder when it is missing, and the registry entry in one step:

beignet make task issues/backfill-search

Register tasks

server/tasks.ts owns central task registration and the operational context used by the CLI:

// server/tasks.ts
import { createServiceActor } from "@beignet/core/ports";
import { defineTasks } from "@beignet/core/tasks";
import type { AppContext } from "@/app-context";
import { issueTasks } from "@/features/issues/tasks";
import { server } from "./index";

export const tasks = defineTasks([...issueTasks] as const);

export async function createTaskContext(): Promise<AppContext> {
  return server.createServiceContext({
    actor: createServiceActor("beignet-cli"),
  });
}

export async function stopTaskContext(): Promise<void> {
  await server.stop();
}

The tasks export is the registry the CLI loads. createTaskContext builds the app context for a run — a service actor plus the app's ports, providers, audit, and devtools wiring — and stopTaskContext shuts providers down after the run finishes. beignet make task keeps this registry updated for you.

Run a task

beignet task run issues.backfill-search --input '{"dryRun":true}'

The CLI loads server/tasks.ts (override with --module), parses --input as JSON against the task's schema, creates the context, runs the handler, and prints the task name, duration, and output. Pass --json for machine-readable output in CI or release jobs.

Testing

Run the task definition directly with runTask and a test context:

import { runTask } from "@beignet/core/tasks";
import { createTestContext } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { backfillSearchTask } from "@/features/issues/tasks";

const makeContext = createTestContext<AppContext>();
const fixture = makeContext();

const output = await runTask(backfillSearchTask, {
  input: { dryRun: true },
  ctx: fixture.ctx,
});

expect(output.dryRun).toBe(true);

Memory ports make assertions cheap: capture logs and audit entries in memory, then assert the task recorded what it did. Keep these tests in features/<feature>/tests/.

Production

Tasks run from bounded entrypoints — a local shell, CI job, release job, or admin worker — so they need no exposed HTTP route. See Going to production for runtime entrypoints and operational auth.