task-send-match.ts5.2 KBView on GitHub
/**
 * Which open tasks does a send just satisfy?
 *
 * The client mirror of the two server predicates that close a task when something goes out:
 *
 *   completeTaskByDraftId          COALESCE(task_output->>'draftId',  task_action_data->>'draftId')  = :draftId
 *   markEmailTasksCompleteByThreadId  task_output->>'kind' = 'email'
 *                                  AND COALESCE(task_output->>'threadId', task_action_data->>'threadId') = :threadId
 *
 * (apps/server/src/services/task-scheduling/execution.ts and
 *  apps/server/src/services/user-tasks/tasks.ts, both routed through output-predicates.ts.)
 *
 * It is a MIRROR, not a guess. The whole value of resolving a task optimistically is that the
 * server is about to agree — a client rule that closes rows the server leaves `todo` buys a few
 * seconds of the right answer followed by the task popping back, which is a worse bug than the
 * lag it was meant to fix. So when the two predicates change, this changes with them, and a send
 * the server does not act on (a free-text LinkedIn/WhatsApp reply, a Slack message with no
 * `draftId`) deliberately matches nothing here either.
 *
 * Pure and store-free so it is unit-testable on plain rows; the surfaces it writes to live in
 * resolve-tasks-on-send.ts.
 */

/** The produced-artifact keys a send can be matched against. Mirrors `ProducedKey` server-side. */
type ProducedKey=[redacted] | 'threadId';

/**
 * The minimum a task row needs for this module to read it. Deliberately structural: the same
 * matcher runs over `HydratedUserTask` (the slice), `ConversationUserTask` (the conversation
 * query) and the raw rows sitting in the React Query caches, and those three types are only
 * nominally different.
 */
export interface SendMatchableTask {
  id: string;
  status: string;
  conversationId?: string | null;
  taskOutput?: unknown;
  taskActionData?: unknown;
}

/** What a send produced. Every field is optional — a surface supplies what it actually knows. */
export interface SendArtifact {
  /**
   * The Cedar draft this send consumed. Closes a task of ANY output kind, exactly as
   * `completeTaskByDraftId` does — the draft is the artifact, whatever channel carried it.
   */
  draftId?: string | null;
  /**
   * The email thread this send landed in. Closes EMAIL-output tasks only. A calendar or
   * reminder task riding the legacy email channel produces no email, so a thread it is
   * attached to going out is not evidence it was done — see `isOutputKind`.
   */
  threadId?: string | null;
  /**
   * Tasks the caller is closing by id, because it makes the `completeTask` call itself rather
   * than relying on a reverse lookup (`sendSlackDraftFromTask` is the case: it sends, then
   * completes the task it was handed). Still a mirror of a server write — just one whose
   * subject the caller already knows, so no predicate has to find it.
   */
  taskIds?: readonly string[] | null;
}

/** The output kind a task declares, read off the output axis only — as `isOutputKind` does. */
function outputKind(task: SendMatchableTask): string | null {
  const output = task.taskOutput as { kind?: unknown } | null | undefined;
  return typeof output?.kind === 'string' ? output.kind : null;
}

/**
 * The value of a produced-artifact key, output axis first, legacy payload second.
 *
 * The COALESCE is not belt-and-braces — it is the server's own shape (`outputKey`), kept while
 * `task_action_data` is still the column half the older rows were written into. Reading one axis
 * would silently stop matching rows the server still closes, and "stopped matching" on a
 * completion predicate looks exactly like "there was nothing to close".
 */
export function producedKey(task: SendMatchableTask, key=[redacted] string | null {
  const output = task.taskOutput as Record<string, unknown> | null | undefined;
  const fromOutput = output?.[key];
  if (typeof fromOutput === 'string' && fromOutput) return fromOutput;

  const action = task.taskActionData as Record<string, unknown> | null | undefined;
  const fromAction = action?.[key];
  return typeof fromAction === 'string' && fromAction ? fromAction : null;
}

/** Does sending `artifact` close this task? `todo` rows only — the server predicates say so too. */
export function isSatisfiedBySend(task: SendMatchableTask, artifact: SendArtifact): boolean {
  if (task.status !== 'todo') return false;

  if (artifact.taskIds?.includes(task.id)) return true;

  if (artifact.draftId && producedKey(task, 'draftId') === artifact.draftId) return true;

  return (
    !!artifact.threadId &&
    outputKind(task) === 'email' &&
    producedKey(task, 'threadId') === artifact.threadId
  );
}

/**
 * The ids of every task in `tasks` that this send closes, de-duplicated.
 *
 * Returns ids rather than rows because the caller has to apply the resolution across several
 * stores that each hold their own copy of the row — the id is the only thing they share.
 */
export function tasksSatisfiedBySend(
  tasks: Iterable<SendMatchableTask>,
  artifact: SendArtifact,
): string[] {
  if (!artifact.draftId && !artifact.threadId && !artifact.taskIds?.length) return [];

  const ids = new Set<string>();
  for (const task of tasks) {
    if (task?.id && isSatisfiedBySend(task, artifact)) ids.add(task.id);
  }
  return [...ids];
}