/** * Request Service — CRUD operations for V3 Request entities * * Requests represent user-level goals. They are stored as JSON files * in `{projectPath}/.crewly/requests/{id}.json`. * * @module services/v3/request.service */ import { type Request, type CreateRequestInput, type UpdateRequestInput } from '../../types/v2/index.js'; import { type WorkItem } from '../../types/v2/work-item.types.js'; import { type PlannedTask } from './v3-data.service.js'; import type { EventBusService } from '../event-bus/event-bus.service.js'; /** * Duck-typed interface RequestService uses to query child WorkItem * states for the {@link RequestService.update} → done gate. * * Defined as an interface (not a TaskPoolService import) so request.service * does NOT acquire a static dependency on task-pool.service. The dependency * direction stays one-way: TaskPool → Request (via Bug B's * `IRequestWorkItemLinker`), Request → TaskPool only via this duck-typed * setter (Bug C). This breaks the otherwise-circular type graph that would * form if either side imported the other concretely. * * Tests pass a fake; production wires the real TaskPoolService instance via * {@link RequestService.setTaskPoolService}. * * P1 Bug C (Pool umbrella WI 72ca743a-3a66-4d0e-a6cf-1a861f849dbd, sub-WI * 72ca743a Bug C): the F9 fixture closed a Request to status=done * with a non-terminal child WI in the pool. The gate fetches each child by * id and refuses the transition if any child is in a non-terminal state. */ export interface IWorkItemQueryable { /** * Look up a WorkItem by id. Returns `null` if no item has that id — * mirrors {@link TaskPoolService.findWorkItem}'s null-fallthrough idiom. * * @param workItemId - WorkItem id to look up * @returns The WorkItem, or `null` if no item has that id */ findWorkItem(workItemId: string): Promise; } /** * Structured error thrown when {@link RequestService.update} refuses to * transition a Request to `done` because at least one of its child * WorkItems is still in a non-terminal state. * * Distinct error class (not a generic `Error`) so callers can branch on * `instanceof RequestStillHasOpenChildrenError` and surface a meaningful * message — Slack bridge, REST consumers, audit, all want to differentiate * "still has open children" from generic transition-validation errors. * * The structured payload exposes `requestId`, `openChildIds`, and * `openChildCount` so consumers can render actionable messages without * re-querying the pool. */ export declare class RequestStillHasOpenChildrenError extends Error { /** The Request id that was refused. */ readonly requestId: string; /** WorkItem ids of children that are still in non-terminal status. */ readonly openChildIds: readonly string[]; /** Convenience: openChildIds.length, included for log/format symmetry. */ readonly openChildCount: number; constructor(args: { requestId: string; openChildIds: readonly string[]; }); } /** * Wire the EventBusService that {@link RequestService} should publish * lifecycle events to. Called once from the backend boot path before any * Request creation can fire. * * @param bus - The live EventBusService, or null to clear (tests) */ export declare function setRequestServiceEventBus(bus: EventBusService | null): void; /** * Service for managing Request entities on disk. * * Uses singleton pattern for consistent state across the backend. * All persistence is file-based (one JSON file per Request). * * @example * ```typescript * const service = RequestService.getInstance('/path/to/project'); * const req = await service.create({ title: 'Deploy staging', ... }); * ``` */ export declare class RequestService { private readonly projectPath; private static instance; private readonly logger; private readonly requestsDir; /** * Optional TaskPool reference used by the {@link update} → done gate * to verify that all child WorkItems are in terminal state before the * Request itself can close. Wired via {@link setTaskPoolService} from * the backend boot path (P1 Bug C — Pool umbrella WI 72ca743a). * * When `null`, the gate degrades gracefully — closure proceeds with a * warning log. This preserves backward compatibility for tests and * boot orderings that happen to call `update` before the wiring step. * Production paths always wire the dependency before any user-facing * Request close request can land. */ private taskPoolService; /** * Told after every write of a Request file (create, update, archive, * delete) — e.g. the "waiting on you" uploader, which re-reads 待验收 when * a ticket changes. Listener errors are swallowed. */ private readonly changeListeners; /** * Creates a new RequestService. * * @param projectPath - Absolute path to the project root */ private constructor(); /** * Gets the singleton instance. * * @param projectPath - Absolute path to the project root * @returns The singleton RequestService instance */ static getInstance(projectPath?: string): RequestService; /** * Resets the singleton instance. For test isolation only. */ static resetInstance(): void; /** * Wire the TaskPool-queryable used by the Request → done gate * (P1 Bug C — Pool umbrella WI 72ca743a). * * Called from the backend boot path after both services exist. Mirrors * Bug B's {@link TaskPoolService.setRequestService} — same setter shape, * idempotent, accepts `null` to clear in tests. Keeps the dependency * direction Request → TaskPool one-way and duck-typed, so neither * service needs a static import of the other. * * @param svc - The TaskPoolService instance (or any object satisfying * {@link IWorkItemQueryable}), or null to clear. */ setTaskPoolService(svc: IWorkItemQueryable | null): void; /** * Absolute path of the directory Request files live in. The ticket intake * keeps its number counter next to them so numbering is per data dir. * * @returns The requests directory */ getRequestsDir(): string; /** * Subscribe to Request writes (create, update, archive, delete). * * @param listener - Called with the Request id after the write landed * @returns Unsubscribe function */ onChange(listener: (id: string) => void): () => void; /** * Tell the change listeners; one failing listener never breaks a write. * * @param id - Request id that changed */ private emitChange; /** * Returns the file path for a request JSON file. * * @param requestId - The request ID * @returns Absolute path to the request JSON file */ private getFilePath; /** * Loads a single Request from disk. * * @param requestId - The request ID * @returns The Request or null if not found */ private load; /** * Saves a Request to disk atomically. * * @param request - The Request to persist */ private save; /** * Creates a new Request. * * Publishes a `request:created` event after successful save so the * INBOUND-1 SLA subscriber (and any other in-process listener) can react * to inbound user goals. Event publication is best-effort — a failure to * publish does NOT roll back the persisted Request, but is logged. * * @param input - Creation input * @returns The created Request * @throws Error if validation fails */ create(input: CreateRequestInput): Promise; /** * Publish the `request:created` event. Synchronous best-effort — failures * are logged but do not propagate to the caller (Request persistence is * already complete and must not be rolled back). * * @param request - The Request that was just persisted */ private publishCreatedEvent; /** * Gets a Request by ID. * * @param id - Request ID * @returns The Request or null */ getById(id: string): Promise; /** * Lists all Requests. * * @returns Array of all Requests, sorted by createdAt descending */ listAll(): Promise; /** * Updates a Request with partial fields. * * @param id - Request ID * @param updates - Fields to update * @returns The updated Request * @throws Error if not found or invalid transition */ update(id: string, updates: UpdateRequestInput): Promise; /** * P1 Bug C gate helper — verify that every WorkItem id in * `request.workItemIds[]` resolves to a WorkItem in terminal state. * * Throws {@link RequestStillHasOpenChildrenError} when at least one child * is non-terminal. Missing children (returned `null` by * {@link IWorkItemQueryable.findWorkItem}) are treated as **terminal / * harmless** — a deleted-or-purged WI does not block its parent's * closure. This matches the option-a "graceful degrade" stance ORC took * on historical orphan WIs and avoids a deadlock between data-cleanup * jobs and lifecycle gates. * * Semantics: non-terminal = anything not in * {@link TERMINAL_WORK_ITEM_STATUSES} (`done`, `verified`, `cancelled`). * `failed` and `rejected` are treated as non-terminal because the WI * state-machine in {@link WORK_ITEM_TRANSITIONS} explicitly allows them * to re-enter `queued` — work the user requested is not actually * complete. * * @param request - The Request whose children to verify * @param ignoreDead - The ticket was accepted: children in a dead status * ({@link TICKET_CONSTANTS.DEAD_WORK_ITEM_STATUSES}) do not block * @throws {@link RequestStillHasOpenChildrenError} when any child is * non-terminal. */ private assertAllChildrenTerminal; /** * Deletes a Request from disk. Used for purging old completed Requests. * * @param id - The Request ID to delete */ /** * Move a Request file into `requests/archive/` (ticket loop Phase 3: done * tickets leave the board after 30 days but are never deleted). `listAll` * reads only the top-level directory, so archived tickets drop out of every * list while staying on disk. * * @param id - Request id * @returns True when a file was moved */ archive(id: string): Promise; delete(id: string): Promise; /** * Adds a WorkItem ID to a Request's workItemIds array. * * @param requestId - The Request ID * @param workItemId - The WorkItem ID to link * @returns The updated Request, or null if Request not found */ linkWorkItem(requestId: string, workItemId: string): Promise; /** * Finds a Request by its source conversation item ID. * Used for deduplication — avoids creating duplicate Requests for the same message. * * @param sourceConversationItemId - The conversation item ID * @returns The matching Request, or null */ findBySourceConversationItemId(sourceConversationItemId: string): Promise; /** * Decomposes a user message into a proposed list of ProjectTasks. * * This is the "TodoWrite-style" planning phase: given a raw user message, * it classifies the intent and returns structured tasks that can be * reviewed, approved, and then executed. * * Currently uses keyword-based decomposition (same logic as Mission * decomposition in V3DataService). Can be upgraded to AI-based planning * in the future without changing the API contract. * * @param message - The raw user message text * @param options - Optional planning configuration * @returns A RequestPlan with proposed tasks and metadata * * @example * ```typescript * const plan = await requestService.plan('Build a new auth system'); * // plan.tasks → [{ title: 'Design...', ... }, { title: 'Implement...', ... }, ...] * ``` */ plan(message: string, options?: PlanOptions): Promise; } /** * Strategy used for task decomposition. */ export type PlanStrategy = 'build' | 'fix' | 'generic' | 'none'; /** * Optional configuration for the plan method. */ export interface PlanOptions { /** Maximum number of tasks to generate */ maxTasks?: number; /** Preferred priority for generated tasks */ defaultPriority?: 'high' | 'medium' | 'low'; } /** * Result of planning a user request into tasks. */ export interface RequestPlan { /** The original user message */ message: string; /** Proposed tasks derived from the message */ tasks: PlannedTask[]; /** Human-readable explanation of the decomposition */ reasoning: string; /** Which planning strategy was applied */ strategy: PlanStrategy; } //# sourceMappingURL=request.service.d.ts.map