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];
}