import { createHash } from "node:crypto"; import { z } from "zod"; import type { HitlResumeTokenSignerSeam, HitlTimeoutJobSchedulerSeam } from "../contracts/hitlSeamTypes"; import type { HumanTaskRecord, HumanTaskStore } from "../contracts/humanTaskStoreTypes"; import type { HumanTaskHandle, NodeActivationId, NodeId, PersistedRunState, PersistedSuspensionEntry, RunId, SuspensionRequest, WorkflowExecutionRepository, } from "../types"; import type { TelemetryScope } from "../contracts/telemetryTypes"; import { CodemationTelemetryAttributeNames } from "../contracts/CodemationTelemetryAttributeNames"; import { RunSuspendedError } from "./RunSuspendedError"; export { RunSuspendedError }; export class NodeSuspensionHandler { constructor( private readonly workflowExecutionRepository: WorkflowExecutionRepository, private readonly humanTaskStore?: HumanTaskStore, private readonly tokenSigner?: HitlResumeTokenSignerSeam, private readonly timeoutScheduler?: HitlTimeoutJobSchedulerSeam, private readonly workspaceId?: string, ) {} async handle(args: { runId: RunId; nodeId: NodeId; activationId: NodeActivationId; itemIndex: number; suspensionRequest: SuspensionRequest; state: PersistedRunState; telemetry?: TelemetryScope; }): Promise { const taskId = `htask_${globalThis.crypto.randomUUID()}`; const { timeout, onTimeout, deliver, decisionSchema, subject, metadata } = args.suspensionRequest.request; const timeoutMs = this.parseDurationMs(timeout); const expiresAt = new Date(Date.now() + timeoutMs); const decisionSchemaHash = this.hashSchema(decisionSchema); const decisionSchemaJson = this.schemaToJson(decisionSchema); let resumeUrl = ""; let resumeTokenHash = ""; if (this.tokenSigner) { const token = this.tokenSigner.sign({ taskId, expiresAt, schemaHash: decisionSchemaHash }); resumeUrl = token; resumeTokenHash = this.tokenSigner.hashToken(token); } const handle: HumanTaskHandle = { taskId, runId: args.runId, nodeId: args.nodeId, expiresAt, resumeUrl, ...(metadata !== undefined ? { metadata } : {}), }; const channel = (metadata as Record | undefined)?.["channel"]; await args.telemetry?.addSpanEvent?.({ name: "hitl.task.created", attributes: { [CodemationTelemetryAttributeNames.hitlTaskId]: taskId, [CodemationTelemetryAttributeNames.hitlChannel]: typeof channel === "string" ? channel : "unknown", [CodemationTelemetryAttributeNames.runId]: args.runId, [CodemationTelemetryAttributeNames.nodeId]: args.nodeId, expiresAt: expiresAt.toISOString(), }, }); let deliveryRef: Awaited>; try { deliveryRef = await deliver(handle); } catch (deliverError) { await args.telemetry?.addSpanEvent?.({ name: "hitl.task.delivery_failed", attributes: { [CodemationTelemetryAttributeNames.hitlTaskId]: taskId, [CodemationTelemetryAttributeNames.hitlChannel]: typeof channel === "string" ? channel : "unknown", error: deliverError instanceof Error ? deliverError.message : String(deliverError), }, }); throw deliverError; } if (this.humanTaskStore) { const record: HumanTaskRecord = { id: taskId, runId: args.runId, workflowId: args.state.workflowId, workspaceId: this.workspaceId ?? undefined, nodeId: args.nodeId, activationId: args.activationId, itemIndex: args.itemIndex, status: "pending", channel: "local", subject, metadata: (metadata as Record) ?? {}, decisionSchemaJson, decisionSchemaHash, onTimeout, deliveryRef, resumeTokenHash: resumeTokenHash || "no-token", expiresAt, createdAt: new Date(), }; await this.humanTaskStore.create(record); } if (this.timeoutScheduler) { await this.timeoutScheduler.enqueueTimeoutJob({ taskId, expiresAt }); } const entry: PersistedSuspensionEntry = { taskId, nodeId: args.nodeId, activationId: args.activationId, itemIndex: args.itemIndex, decisionSchemaHash, deliveryRef, timeoutAt: expiresAt.toISOString(), onTimeout, }; const existingSuspensions = args.state.suspension ?? []; const updatedState: PersistedRunState = { ...args.state, status: "suspended", suspension: [...existingSuspensions, entry], }; await this.workflowExecutionRepository.save(updatedState); throw new RunSuspendedError(args.runId, taskId); } private parseDurationMs(duration: string): number { const shorthand = /^(\d+(?:\.\d+)?)(s|m|h|d)$/i.exec(duration); if (shorthand) { const value = parseFloat(shorthand[1]!); const unit = shorthand[2]!.toLowerCase(); const multipliers: Record = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, }; return value * multipliers[unit]!; } const iso = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration); if (iso) { const days = parseFloat(iso[1] ?? "0"); const hours = parseFloat(iso[2] ?? "0"); const minutes = parseFloat(iso[3] ?? "0"); const seconds = parseFloat(iso[4] ?? "0"); return (days * 86_400 + hours * 3_600 + minutes * 60 + seconds) * 1_000; } throw new Error(`NodeSuspensionHandler: unrecognised duration format: "${duration}"`); } private hashSchema(schema: unknown): string { const json = this.schemaToJson(schema); return createHash("sha256").update(json).digest("hex"); } private schemaToJson(schema: unknown): string { if (schema instanceof z.ZodType) { return JSON.stringify(z.toJSONSchema(schema)); } if (typeof (schema as { toJSON?: unknown }).toJSON === "function") { return JSON.stringify((schema as { toJSON: () => unknown }).toJSON()); } return JSON.stringify(schema); } }