/** * Play Node Scope — the explicit execution location stamped onto every provider * tool call so usage/cost facts can be attributed back to a graph node. * * See ADR 0018 (Observed Provider Attribution). Before this module the only * durable link between a provider call and a play graph node was a stdout regex * (`Calling tool: `), which collapsed N calls into one node transition and * could not distinguish two nodes using the same tool. The scope below travels * with the outbound `/api/v2/integrations//execute` request and lands on * `usageEvents.stepBlockId`, which already exists and is already written. * * The scope is deliberately made of facts the runtime *knows* at the call site: * the tool id, the `ctx.tools.execute` id, and — for row-scoped calls — the * runtime sheet column and artifact table namespace. It never guesses a graph * node id; node resolution is a read-time join against the static pipeline and * degrades to an explicit `unattributed` bucket instead of a wrong node. */ /** Execution location of a single logical `ctx.tools.execute` invocation. */ export type PlayNodeScope = { /** Provider tool id (`dropleads_search_people`). Always present. */ toolId: string; /** The author-supplied `ctx.tools.execute(, ...)` key, when known. */ callKey: string | null; /** Runtime Sheet column the call fed, for row-scoped calls. */ column: string | null; /** Artifact table namespace of the enclosing map, for row-scoped calls. */ tableNamespace: string | null; }; /** Wire form of {@link PlayNodeScope} on the tool-execute request metadata. */ export type PlayNodeScopeWire = { tool_id: string; call_key?: string; column?: string; table_namespace?: string; }; export const PLAY_NODE_SCOPE_ENCODING_VERSION = 'pn1'; /** * Node id used when a usage fact carries no resolvable scope. Old events * written before this contract, and calls the read path cannot place on the * graph, aggregate here rather than being dropped or guessed onto a node. */ export const UNATTRIBUTED_PLAY_NODE_ID = 'unattributed'; /** Per-component cap. Keeps the encoded key comfortably under Convex limits. */ const MAX_COMPONENT_LENGTH = 128; function normalizeComponent(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed) return null; return trimmed.length > MAX_COMPONENT_LENGTH ? trimmed.slice(0, MAX_COMPONENT_LENGTH) : trimmed; } /** * Build a scope from raw call-site facts. Returns null when there is no tool * id, because a scope without a tool identity cannot be joined to anything. */ export function buildPlayNodeScope(input: { toolId: unknown; callKey?: unknown; column?: unknown; tableNamespace?: unknown; }): PlayNodeScope | null { const toolId = normalizeComponent(input.toolId); if (!toolId) return null; return { toolId, callKey: normalizeComponent(input.callKey), column: normalizeComponent(input.column), tableNamespace: normalizeComponent(input.tableNamespace), }; } export function playNodeScopeToWire(scope: PlayNodeScope): PlayNodeScopeWire { return { tool_id: scope.toolId, ...(scope.callKey ? { call_key: scope.callKey } : {}), ...(scope.column ? { column: scope.column } : {}), ...(scope.tableNamespace ? { table_namespace: scope.tableNamespace } : {}), }; } export function playNodeScopeFromWire(value: unknown): PlayNodeScope | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const wire = value as Record; return buildPlayNodeScope({ toolId: wire.tool_id ?? wire.toolId, callKey: wire.call_key ?? wire.callKey, column: wire.column, tableNamespace: wire.table_namespace ?? wire.tableNamespace, }); } function encodeComponent(value: string): string { return encodeURIComponent(value); } /** * Durable key form written to `usageEvents.stepBlockId`. * * `pn1|tool=|call=|col=|ns=` with percent-encoded components and * empty components omitted. Stable and parseable in both directions so a read * path never has to pattern-match free text. */ export function encodePlayNodeScope(scope: PlayNodeScope): string { const parts = [ `${PLAY_NODE_SCOPE_ENCODING_VERSION}`, `tool=${encodeComponent(scope.toolId)}`, ]; if (scope.callKey) parts.push(`call=${encodeComponent(scope.callKey)}`); if (scope.column) parts.push(`col=${encodeComponent(scope.column)}`); if (scope.tableNamespace) { parts.push(`ns=${encodeComponent(scope.tableNamespace)}`); } return parts.join('|'); } /** * Parse a `stepBlockId` back into a scope. Returns null for legacy workflow * block ids and for anything that is not this encoding — callers must treat * null as "unattributed", never as an error. */ export function decodePlayNodeScope(value: unknown): PlayNodeScope | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed.startsWith(`${PLAY_NODE_SCOPE_ENCODING_VERSION}|`)) return null; const fields: Record = {}; for (const part of trimmed.split('|').slice(1)) { const separator = part.indexOf('='); if (separator <= 0) continue; const key = part.slice(0, separator); try { fields[key] = decodeURIComponent(part.slice(separator + 1)); } catch { // A malformed component must not poison the whole rollup. return null; } } return buildPlayNodeScope({ toolId: fields.tool, callKey: fields.call, column: fields.col, tableNamespace: fields.ns, }); } /** Short human label stored beside the key in `usageEvents.stepAlias`. */ export function playNodeScopeAlias(scope: PlayNodeScope): string { if (scope.column && scope.tableNamespace) { return `${scope.tableNamespace}.${scope.column}`; } if (scope.column) return scope.column; return scope.callKey ?? scope.toolId; }