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, type TaskRunContextArgs } from "@beignet/core/tasks";
import type { AppContext } from "@/app-context";
import { issueTasks } from "@/features/issues/tasks";
import { getServer } from "./index";

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

export async function createTaskContext(
  args: TaskRunContextArgs,
): Promise<AppContext> {
  const server = await getServer();

  return server.createServiceContext({
    actor: createServiceActor("beignet-cli"),
    tenantId: args.tenant
      ? await resolveTenantId(server, args.tenant)
      : undefined,
  });
}

async function resolveTenantId(
  server: Awaited<ReturnType<typeof getServer>>,
  tenant: string,
): Promise<string> {
  const workspace =
    (await server.ports.workspaces.findBySlug(tenant)) ??
    (await server.ports.workspaces.findById(tenant));
  if (!workspace) {
    throw new Error(`Unknown tenant "${tenant}". Pass a workspace slug or id.`);
  }

  return workspace.id;
}

export async function stopTaskContext(): Promise<void> {
  const server = await getServer();

  await server.stop();
}

The tasks export is the registry the CLI loads. createTaskContext receives TaskRunContextArgs — the task definition, task name, schema-parsed input, and the optional tenant value from --tenant — and builds the app context for a run: a service actor plus the app's ports, providers, audit, and devtools wiring. The app decides how to resolve the tenant string to a tenant id, for example by looking up a slug. stopTaskContext receives the same arguments and 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 --tenant acme --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. --tenant scopes the run to a tenant without task input schemas having to declare a tenant field; it flows to createTaskContext separately from --input. Pass --json for machine-readable output in CI or release jobs.

When createTaskContext(...) returns ports.errorReporter, the CLI reports a terminal task failure and performs a bounded flush before stopTaskContext(...). Direct runTask(...) calls remain caller-owned and can use tryReportException(...) at their outer execution boundary.

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 Runtime recipes for task runtime entrypoints and operational auth.