/** * modules/tasks.ts — Tasks as the second Papyrus-native registered module * (step 5, continued, of the incremental refactor in * reducing-papyrus-consumer-change-amplification-with-modules--pvdo). * * Deliberately more representative than modules/notes.ts: Tasks owns real schema * (task_events, task_focus, task_scopes and their migrations, still in src/db.ts for * this slice — module-owned migrations are a separate follow-up, * add-a-module-migration-ledger-keyed-by-moduleid-version-with-3e7k), a much larger * operation surface, and cross-module edges (rules.gate links a rule to a task; * graph.link routes depends_on through Tasks.depend for cycle safety — those two * remain in src/service.ts since they are graph.* / rules.* operations, not tasks.*). * * Task-domain-internal files (task-context.ts, task-execution.ts, domain/task-event.ts, * domain/task-scope.ts) are imported directly — they belong to this bounded context, * unlike a different module's infrastructure. Generic input-parsing helpers come from * ./operation-input.ts, a peer utility at this same modules/ layer with no domain logic * of its own -- not "another module's infrastructure" or the composition root's helpers, * the actual thing this module avoids importing. */ import { summarizeArtifact } from "../artifact/artifact.ts"; import type { ArtifactStore } from "../artifact/artifact-store.ts"; import type { OperationDefinition } from "../module-registry.ts"; import type { SessionIdentity } from "../session-identity/session-identity-service.ts"; import type { Checklist } from "../task/checklist.ts"; import type { TaskEventContext, TaskEventDirection, TaskEventFeedQuery } from "../task/event/task-event.ts"; import type { TaskViewMode } from "../task/scope/task-scope.ts"; import { taskContext } from "../task/task-context.ts"; import { projectTaskExecution } from "../task/task-execution.ts"; import type { TaskMutationRequestContext, TaskStatus, Tasks } from "../task/task-service.ts"; import { type OperationInput, optionalBoolean, optionalNumber, optionalString, optionalStringArray, string } from "./operation-input.ts"; const MODULE_ID = "tasks"; const eventContext = (input: OperationInput): TaskEventContext => ({ actor: optionalString(input, "actor"), source: optionalString(input, "source"), sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"), reason: optionalString(input, "reason"), }); const mutationRequest = (input: OperationInput): TaskMutationRequestContext => ({ key: optionalString(input, "idempotency_key"), caller: optionalString(input, "idempotency_caller"), }); const taskFilter = (input: OperationInput) => ({ status: optionalString(input, "status"), text: optionalString(input, "text"), limit: optionalNumber(input, "limit"), projectRoot: string(input, "project_root"), scope: optionalString(input, "scope") as TaskViewMode | undefined, rootTaskId: optionalString(input, "root_task_id"), sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"), labels: optionalStringArray(input, "labels"), cursor: optionalString(input, "cursor"), }); /** * Registers every tasks.* operation against one Tasks instance. Behavior is unchanged from * the prior inline handlers in src/service.ts. tasks.context needs the raw ArtifactStore * port directly (taskContext is a plain-artifact query, not a Tasks method), so the * composition root passes the same artifacts port it already constructs Tasks with — * this is not "another module's infrastructure", it is the shared port every module writes * through. */ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */ export const TASKS_OPERATION_NAMES = [ "tasks.create", "tasks.update", "tasks.list", "tasks.list_page", "tasks.graph", "tasks.plan", "tasks.show", "tasks.history", "tasks.projects", "tasks.resolve_project", "tasks.register_project", "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus", "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete", "tasks.mutation_status", "tasks.run_gates", "tasks.set_checklist", "tasks.set_gates", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel", "tasks.reopen", "tasks.cancel_subtree", "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus", "tasks.claim", "tasks.heartbeat_lease", "tasks.release_lease", "tasks.lease", "tasks.reap_stale_leases", "tasks.event_feed", ] as const; export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] { const define = (name: string, execute: (input: Input) => Output): OperationDefinition => ({ name, moduleId: MODULE_ID, execute, }); // Enforced only for the operations where session_id is BEHAVIOR-affecting (it selects // which Task Focus row is mutated), not for every session_id-carrying operation -- see // domain/session-identity.ts. A session_id with no registered identity passes through // unchanged (opt-in armor). const guardFocusMutation = (input: OperationInput): void => { sessionIdentity.assertAuthorized(eventContext(input).sessionId, optionalString(input, "session_secret")); }; return [ define("tasks.create", (input: OperationInput) => tasks.create( { title: string(input, "title"), body: optionalString(input, "body"), status: optionalString(input, "status") as TaskStatus | undefined, labels: input.labels as string[] | undefined, extra: input.extra as Record | undefined, gates: input.gates as Parameters[0]["gates"], checklist: input.checklist as Checklist | undefined, templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"), parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"), dependsOn: (input.depends_on ?? input.dependsOn) as string[] | undefined, projectRoot: string(input, "project_root"), projectSource: "cwd", }, eventContext(input), { key: optionalString(input, "idempotency_key"), caller: optionalString(input, "idempotency_caller"), }, ), ), define("tasks.update", (input: OperationInput) => tasks.update( string(input, "id"), { ...(input.title !== undefined ? { title: optionalString(input, "title")! } : {}), ...(input.body !== undefined ? { body: optionalString(input, "body")! } : {}), ...(input.labels !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}), ...(input.status !== undefined ? { status: string(input, "status") as "todo" } : {}), }, eventContext(input), ), ), define("tasks.list", (input: OperationInput) => { const rows = tasks.list(taskFilter(input)); return optionalBoolean(input, "full") === true ? rows : rows.map(summarizeArtifact); }), define("tasks.list_page", (input: OperationInput) => { const page = tasks.listPage(taskFilter(input)); return { ...page, items: optionalBoolean(input, "full") === true ? page.items : page.items.map(summarizeArtifact), }; }), define("tasks.graph", (input: OperationInput) => tasks.graph(taskFilter(input))), define("tasks.plan", (input: OperationInput) => projectTaskExecution(tasks.graph(taskFilter(input)))), define("tasks.show", (input: OperationInput) => tasks.show(string(input, "id"))), define("tasks.history", (input: OperationInput) => tasks.history(string(input, "id"), { limit: optionalNumber(input, "limit"), cursor: optionalNumber(input, "cursor"), direction: optionalString(input, "direction") as TaskEventDirection | undefined, }), ), define("tasks.projects", (input: OperationInput) => tasks.projects(optionalString(input, "query"), optionalNumber(input, "limit"))), define("tasks.resolve_project", (input: OperationInput) => tasks.resolveProject(string(input, "name"))), define("tasks.register_project", (input: OperationInput) => tasks.registerProject( { projectRoot: string(input, "project_root"), name: optionalString(input, "name"), aliases: optionalStringArray(input, "aliases"), }, optionalString(input, "project"), ), ), define("tasks.scope", (input: OperationInput) => tasks.scopeSelection(string(input, "project_root"))), define("tasks.set_scope", (input: OperationInput) => tasks.setView(string(input, "project_root"), string(input, "scope") as TaskViewMode, optionalString(input, "root_task_id")), ), define("tasks.assign_project", (input: OperationInput) => tasks.assignProject(string(input, "id"), string(input, "project_root"), eventContext(input)), ), define("tasks.active", (input: OperationInput) => tasks.active(taskFilter(input))), define("tasks.focused", (input: OperationInput) => tasks.focused(taskFilter(input))), define("tasks.focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.focus(string(input, "id"), eventContext(input)); }), define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input), mutationRequest(input)); }), define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input), mutationRequest(input)); }), define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }), define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })), define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input), mutationRequest(input)), ), define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input), mutationRequest(input)), ), define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input), {}, mutationRequest(input)), ), define("tasks.mutation_status", (input: OperationInput) => tasks.mutationStatus(string(input, "idempotency_key"), optionalString(input, "idempotency_caller")), ), define("tasks.run_gates", (input: OperationInput) => tasks.runGates(string(input, "id"), eventContext(input))), define("tasks.set_checklist", (input: OperationInput) => tasks.setChecklist(string(input, "id"), input.checklist as Checklist)), define("tasks.set_gates", (input: OperationInput) => tasks.setGates(string(input, "id"), input.gates as Parameters[1]), ), define("tasks.context", (input: OperationInput) => taskContext( artifacts, tasks.active(taskFilter(input))?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id)), optionalString(input, "verbosity") === "summary" ? "summary" : "full", ), ), define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input), mutationRequest(input)), ), define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input), mutationRequest(input)), ), define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input), mutationRequest(input)), ), define("tasks.reopen", (input: OperationInput) => tasks.transition(string(input, "id"), "reopen", eventContext(input), mutationRequest(input)), ), define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))), define("tasks.depend", (input: OperationInput) => tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input)), ), define("tasks.undepend", (input: OperationInput) => tasks.undepend(string(input, "id"), string(input, "dependency_id"), eventContext(input)), ), define("tasks.contain", (input: OperationInput) => tasks.contain(string(input, "parent_id"), string(input, "child_id"), eventContext(input)), ), define("tasks.uncontain", (input: OperationInput) => tasks.uncontain(string(input, "parent_id"), string(input, "child_id"), eventContext(input)), ), define("tasks.claim", (input: OperationInput) => tasks.claimLease(string(input, "id"), string(input, "owner"), optionalNumber(input, "ttl_ms"), optionalString(input, "note")), ), define("tasks.heartbeat_lease", (input: OperationInput) => tasks.heartbeatLease(string(input, "id"), string(input, "owner"), string(input, "token"), optionalNumber(input, "ttl_ms")), ), define("tasks.release_lease", (input: OperationInput) => tasks.releaseLease(string(input, "id"), string(input, "owner"), string(input, "token")), ), define("tasks.lease", (input: OperationInput) => tasks.getLease(string(input, "id")) ?? null), define("tasks.reap_stale_leases", () => ({ removed: tasks.reapStaleLeases() })), define("tasks.event_feed", (input: OperationInput) => tasks.eventFeed({ cursor: optionalNumber(input, "cursor"), limit: optionalNumber(input, "limit"), eventTypes: optionalStringArray(input, "event_types") as TaskEventFeedQuery["eventTypes"], }), ), ]; }