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

Start with the Quickstart app and generate a task that checks its operational context:

bun beignet make task todos.check-runtime

The generator creates lib/tasks.ts, the feature definition, and the registries. This complete definition logs one message and returns its input so you can verify execution before adding a backfill or another operation:

// features/todos/tasks/check-runtime.ts
import { z } from "zod";
import { defineTask } from "@/lib/tasks";

export const CheckRuntimeTaskInputSchema = z.object({
  dryRun: z.boolean().default(true),
});
export type CheckRuntimeTaskInput = z.infer<typeof CheckRuntimeTaskInputSchema>;

export const checkRuntimeTask = defineTask("todos.check-runtime", {
  input: CheckRuntimeTaskInputSchema,
  description: "Check the Todo task runtime.",
  async handle({ input, ctx }) {
    ctx.ports.logger.info("Task handled", {
      taskName: "todos.check-runtime",
      dryRun: input.dryRun,
    });
    return { dryRun: input.dryRun };
  },
});

For real work, call use cases and ports from the handler. dryRun is an ordinary input field: a mutating task must explicitly honor it before writing.

Run a task

Prepare the starter database if you have not already, then run the task once:

bun beignet db migrate
bun run typecheck
bun beignet task run todos.check-runtime --input '{"dryRun":true}'

Expect a Task handled log and output containing dryRun: true. No workspace, search index, or job provider is needed. Invalid input such as {"dryRun":"yes"} fails validation before the handler runs. Add --json for machine-readable output.

The CLI loads server/tasks.ts, validates input, creates context, runs the task, and calls stopTaskContext(...) before exiting. --module selects a different registry file.

Register tasks

server/tasks.ts owns the task registry and service context. The generator creates and maintains this wiring; these are excerpts of the default registry:

// server/tasks.ts (excerpt)
import { defineTasks } from "@beignet/core/tasks";
import { todoTasks } from "@/features/todos/tasks";

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

createTaskContext(args) receives the definition, task name, parsed input, and optional tenant value from --tenant. stopTaskContext(ctx, args) receives the created context and those arguments and closes the server. When context includes ports.errorReporter, the CLI reports terminal failures and performs a bounded flush before cleanup.

Tenant-scoped tasks

Use --tenant only when the app has a tenant model. The generated context passes that value as a tenant ID; it does not look up slugs or authorize an operator. Keep operational credentials restricted to callers allowed to run the task.

If your app already has a workspaces repository, you can resolve a slug before creating context. This excerpt assumes findBySlug and findById are declared and wired on that port:

// server/tasks.ts (excerpt)
export async function createTaskContext(args: TaskRunContextArgs): Promise<AppContext> {
  const server = await getServer();
  const workspace = args.tenant
    ? (await server.ports.workspaces.findBySlug(args.tenant)) ??
      (await server.ports.workspaces.findById(args.tenant))
    : undefined;
  if (args.tenant && !workspace) throw new Error(`Unknown tenant "${args.tenant}".`);
  return server.createServiceContext({
    actor: createServiceActor("beignet-cli"),
    tenantId: workspace?.id,
  });
}

Import TaskRunContextArgs from @beignet/core/tasks, createServiceActor from @beignet/core/ports, and your AppContext and getServer as in the generated file. See Tenancy for repository scoping.

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 { checkRuntimeTask } from "@/features/todos/tasks";

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

try {
  const output = await runTask(checkRuntimeTask, {
    input: { dryRun: true },
    ctx: fixture.ctx,
  });
  expect(output.dryRun).toBe(true);
} finally {
  await fixture.dispose();
}

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.

Generated Web task contexts

With framework: "web", beignet make task creates server/tasks.ts using an app-owned createAppServer() export. The generated context factory passes { actor: createServiceActor("beignet-cli"), tenantId } to server.createServiceContext(...); tenantId is included only when --tenant is supplied. Resolve the tenant and any corresponding permissions in the app's service context. The CLI still validates task input before creating context.

The registry starts its server lazily, retries failed startup, and stops an assembled server if context creation fails. stopTaskContext() closes the server after execution and never starts an unused server just to stop it. The CLI validates the required factory and service-context types before writing a new registry. Existing app-owned registries retain their context implementation.