/**
 * RequestStatusUpdateSubscriber — pushes progress updates back to the user's
 * Slack thread while a Request is in flight.
 *
 * **Why this exists.** 2026-05-09 dogfood feedback: when a Slack message
 * triggers a Request that decomposes into Plan/Execute/Review WIs and
 * runs for an hour, the user has no visibility into progress. orc replied
 * once at the start ("[Plan] received, will arrange"), then went silent
 * until — sometimes — a final completion broadcast much later. Owner-Facing
 * Communication Standard (P0-6, PR #493) covers tone + jargon for the
 * messages we DO send, but says nothing about cadence. Sustained silence
 * during long-running work is its own UX failure: it forces the owner
 * to ping "?" or guess whether anything is happening.
 *
 * **What this does.** Subscribes to the canonical lifecycle events on
 * the bus and posts a brief, plain-language status update to the
 * originating Slack thread when:
 *   - a child WorkItem reaches done_by_worker / verified
 *   - a child WI is rejected / blocked / failed
 *   - the parent Request transitions to a terminal status
 *
 * Plus a heartbeat: if a Request has been in flight for longer than
 * the heartbeat interval since the last status post, the subscriber
 * emits a brief "still working" update. This catches the case where
 * a single WI runs for hours without a milestone event firing.
 *
 * **What this is NOT.** This is not a replacement for orc's first reply
 * (the SLA `respond_to_user` path handles that). It's a layer on top
 * that fires only after the Request is past its initial ack and while
 * downstream work is still moving.
 *
 * **Anti-spam.** The subscriber tracks `lastStatusPostedAt` per Request
 * in memory. Non-milestone events within MIN_INTER_POST_MS of the last
 * post are coalesced. Milestones (terminal Request status changes)
 * always go through.
 *
 * @module services/v3/request-status-update.subscriber
 */
import { type ComponentLogger } from '../core/logger.service.js';
import type { EventBusService } from '../event-bus/event-bus.service.js';
import type { Request } from '../../types/v2/request.types.js';
import type { WorkItem } from '../../types/v2/work-item.types.js';
/**
 * Minimal view of {@link RequestService} this subscriber needs.
 * Defining the surface explicitly lets tests pass a fake without dragging
 * in the full service singleton.
 */
export interface RequestServiceLike {
    getById(id: string): Promise<Request | null>;
    listAll(): Promise<Request[]>;
    /**
     * Patch a Request's persisted state. The heartbeat-driven cascade only
     * uses `{ status }` to close stale-but-non-terminal Requests, but the
     * production type signature is wider — keep the signature loose so
     * fakes can pass `Partial<Request>` without compiler grief.
     */
    update(id: string, patch: Partial<Request>): Promise<unknown>;
}
/**
 * Minimal view of {@link TaskPoolService} this subscriber needs.
 *
 * `findWorkItem` returns `WorkItem | undefined | null` because production
 * `TaskPoolService.findWorkItem` returns `null` when the id is unknown,
 * while in-process fakes typically return `undefined`. The subscriber
 * treats both as "no WI" and short-circuits.
 */
export interface TaskPoolServiceLike {
    findWorkItem(id: string): Promise<WorkItem | undefined | null>;
    getAllItems(): Promise<WorkItem[]>;
}
/**
 * Slack post adapter. The production wiring posts via the in-process
 * SlackService; tests pass a stub that records the calls.
 */
export type SlackPoster = (params: {
    channelId: string;
    text: string;
    threadTs: string;
}) => Promise<void>;
export interface RequestStatusUpdateSubscriberDependencies {
    eventBus: EventBusService;
    requestService: RequestServiceLike;
    taskPool: TaskPoolServiceLike;
    slackPoster: SlackPoster;
    /** Heartbeat interval override. If 0, heartbeat is disabled. */
    heartbeatMinutes?: number;
    /** Optional logger override. */
    logger?: ComponentLogger;
    /** Test override for the heartbeat scheduler. Defaults to setInterval. */
    scheduler?: {
        setInterval: (fn: () => void, ms: number) => unknown;
        clearInterval: (handle: unknown) => void;
    };
}
interface ParsedSlackContext {
    channelId: string;
    threadTs: string;
}
/**
 * Parse the `sourceConversationItemId` of a Slack-originated Request into
 * the `(channelId, threadTs)` pair needed to post back to the same thread.
 *
 * Format: `slack-{channelId}-{ts1}.{ts2}` OR `slack-{channelId}-{ts1}-{ts2}`
 * (the bridge stamps both shapes — the second is the underscore-friendly
 * variant). The threadTs we want is `{ts1}.{ts2}` (dot-joined).
 *
 * @param scid - The Request's `sourceConversationItemId`
 * @returns Parsed context, or null if the id is not a Slack id we recognise
 */
/**
 * Compute a stable, content-only fingerprint of a Request's child-WI
 * state. Two snapshots produce the same fingerprint iff every status
 * count is equal — so a heartbeat that would say "still 3 blocked, 0
 * done" matches the previous "still 3 blocked, 0 done" and gets
 * suppressed by {@link RequestStatusUpdateSubscriber.runHeartbeat}.
 *
 * Uses status counts only — not WI ids or titles — so cosmetic
 * re-decompositions of the same Request (different uuids, same shape)
 * still match. WI churn that doesn't move the count needle isn't a
 * progress signal.
 *
 * @param childWIs - All WIs whose `requestId` matches the parent
 * @returns A short string fingerprint, e.g. `done=1,running=0,queued=0,blocked=3,failed=0,total=4`
 */
export declare function computeStateFingerprint(childWIs: WorkItem[]): string;
export declare function parseSlackThreadContext(scid: string | undefined): ParsedSlackContext | null;
export declare class RequestStatusUpdateSubscriber {
    private readonly eventBus;
    private readonly requestService;
    private readonly taskPool;
    private readonly slackPoster;
    private readonly heartbeatMinutes;
    private readonly logger;
    private readonly scheduler;
    private unsubscribers;
    private heartbeatHandle;
    private started;
    /** In-memory map: requestId → epoch ms of last status post. */
    private readonly lastPostedAt;
    /**
     * In-memory map: requestId → state-fingerprint of the last heartbeat
     * post. The heartbeat sweep skips a Request if its current fingerprint
     * matches the last one — there's no point telling the user "still 3
     * blocked" once an hour when nothing has changed since the previous
     * "still 3 blocked" message.
     *
     * Bug shape (2026-05-09 dogfood): a Slack thread received 7+ identical
     * "⏳ 还在做。已完成 0/4, 进行中 0, 排队 0, 卡住 3" messages over the
     * day because the WI state genuinely hadn't moved (Plan WI was wedged
     * blocked) but the heartbeat cadence faithfully kept firing. Plain-
     * language UX rule: a "no change" heartbeat IS noise, not signal.
     *
     * Fingerprint is derived from the {done, running, queued, blocked,
     * failed, total} counts (see {@link buildHeartbeatText}). Only counts —
     * not WI ids or titles — so cosmetic re-decompositions don't trigger
     * a fresh ping.
     */
    private readonly lastHeartbeatFingerprint;
    /** In-flight async dispatch promises (test affordance). */
    private readonly pendingDispatches;
    constructor(deps: RequestStatusUpdateSubscriberDependencies);
    /**
     * Subscribe to bus events + start the heartbeat sweep. Idempotent.
     */
    start(): void;
    /**
     * Detach subscriptions + stop heartbeat. Safe to call repeatedly.
     */
    stop(): void;
    /**
     * Wait for in-flight async dispatches to settle. Test affordance.
     */
    flushPending(): Promise<void>;
    /**
     * Manual trigger for the heartbeat sweep. Test affordance + ops button.
     */
    runHeartbeat(): Promise<number>;
    private handleTaskEvent;
    /**
     * Best-effort `workItemId` extraction from an event payload. Newer
     * publishers set the canonical field; older ones encode it on
     * `eventId` (`task:done_by_worker:{workItemId}` shape).
     */
    private extractWorkItemId;
    private buildEventText;
    private buildHeartbeatText;
    /**
     * Close a Request whose every child WI has reached a terminal status
     * but whose own status hasn't been cascaded. Picks `done` if any
     * child landed on a success-shaped terminal; otherwise `cancelled`.
     *
     * Honours `REQUEST_TRANSITIONS` — `ready → done` is illegal in the
     * state machine, so for `ready` Requests we step through `running`
     * first (a single `running` hop is harmless because the Request is
     * about to land in a terminal state immediately after).
     *
     * Best-effort. Logs but does not throw on failure: the heartbeat
     * sweep must keep moving across other Requests.
     */
    private closeStaleRequest;
    private safeDispatch;
}
export {};
//# sourceMappingURL=request-status-update.subscriber.d.ts.map