@workglow/task-graph 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js.map
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { TaskIdType } from \"./TaskTypes\";\n\n// ========================================================================\n// Entitlement Types\n// ========================================================================\n\n/**\n * Hierarchical entitlement identifier.\n * Uses colon-separated namespacing: \"network\", \"network:http\", \"network:websocket\"\n * A grant of \"network\" implicitly covers \"network:http\" and \"network:websocket\".\n */\nexport type EntitlementId = string;\n\n/**\n * A single entitlement declaration.\n */\nexport interface TaskEntitlement {\n /** Hierarchical identifier, e.g. \"network:http\", \"credential:anthropic\", \"code-execution:javascript\" */\n readonly id: EntitlementId;\n /** Human-readable reason why this entitlement is needed */\n readonly reason?: string;\n /** Whether this entitlement is optional (task can degrade gracefully without it) */\n readonly optional?: boolean;\n /**\n * Specific resources this entitlement applies to.\n * E.g. URL patterns for network, model IDs for ai:model, server names for mcp.\n * When undefined, the entitlement applies broadly.\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Complete entitlement declaration for a task or graph.\n */\nexport interface TaskEntitlements {\n /** List of entitlements required */\n readonly entitlements: readonly TaskEntitlement[];\n}\n\n/**\n * An entitlement with origin tracking (which task(s) require it).\n */\nexport interface TrackedTaskEntitlement extends TaskEntitlement {\n /** Task IDs that require this entitlement */\n readonly sourceTaskIds: readonly TaskIdType[];\n}\n\n/**\n * Entitlements with optional origin tracking.\n */\nexport interface TrackedTaskEntitlements {\n readonly entitlements: readonly TrackedTaskEntitlement[];\n}\n\n// ========================================================================\n// Well-Known Entitlement Constants\n// ========================================================================\n\n/**\n * Well-known entitlement categories. Tasks may also use custom IDs beyond these.\n */\nexport const Entitlements = {\n // Network\n NETWORK: \"network\",\n NETWORK_HTTP: \"network:http\",\n NETWORK_WEBSOCKET: \"network:websocket\",\n NETWORK_PRIVATE: \"network:private\",\n\n // File system\n FILESYSTEM: \"filesystem\",\n FILESYSTEM_READ: \"filesystem:read\",\n FILESYSTEM_WRITE: \"filesystem:write\",\n\n // Code execution\n CODE_EXECUTION: \"code-execution\",\n CODE_EXECUTION_JS: \"code-execution:javascript\",\n\n // Credentials\n CREDENTIAL: \"credential\",\n\n // AI models\n AI: \"ai\",\n AI_MODEL: \"ai:model\",\n AI_INFERENCE: \"ai:inference\",\n\n // MCP\n MCP: \"mcp\",\n MCP_TOOL_CALL: \"mcp:tool-call\",\n MCP_RESOURCE_READ: \"mcp:resource-read\",\n MCP_PROMPT_GET: \"mcp:prompt-get\",\n MCP_STDIO: \"mcp:stdio\",\n\n // Storage / database\n STORAGE: \"storage\",\n STORAGE_READ: \"storage:read\",\n STORAGE_WRITE: \"storage:write\",\n\n // Browser automation\n BROWSER_CONTROL: \"browser\",\n BROWSER_CONTROL_LOCAL: \"browser:local\",\n BROWSER_CONTROL_CLOUD: \"browser:cloud\",\n BROWSER_CONTROL_NAVIGATE: \"browser:navigate\",\n BROWSER_CONTROL_EVALUATE: \"browser:evaluate\",\n BROWSER_CONTROL_CREDENTIAL: \"browser:credential\",\n} as const;\n\n// ========================================================================\n// Empty Entitlements Singleton\n// ========================================================================\n\n/** Shared empty entitlements object to avoid unnecessary allocations */\nexport const EMPTY_ENTITLEMENTS: TaskEntitlements = Object.freeze({\n entitlements: Object.freeze([]),\n});\n\n// ========================================================================\n// Utility Functions\n// ========================================================================\n\n/**\n * Check if a granted entitlement covers a required entitlement.\n * \"network\" covers \"network:http\" (parent covers child in hierarchy).\n */\nexport function entitlementCovers(granted: EntitlementId, required: EntitlementId): boolean {\n return required === granted || required.startsWith(granted + \":\");\n}\n\n/**\n * A grant declaration — what a consumer is willing to allow.\n * Unlike TaskEntitlement (which declares what a task *needs*), this declares what is *permitted*.\n */\nexport interface EntitlementGrant {\n /** Entitlement ID to grant. Hierarchy applies: granting \"network\" covers \"network:http\". */\n readonly id: EntitlementId;\n /**\n * Specific resources this grant covers.\n * - undefined → broad grant, covers all resources for this entitlement\n * - string[] → scoped grant, only covers requirements whose resources are a subset\n *\n * Supports glob-style patterns with any number of `*` wildcards.\n * Each `*` matches zero or more characters of any kind, including `/`.\n * - \"/tmp/*\" covers \"/tmp/data.json\" and \"/tmp/subdir/file.txt\"\n * - \"*.example.com\" covers \"api.example.com\"\n * - \"file-*-v*.json\" covers \"file-data-v2.json\"\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Check if a single grant resource pattern matches a single required resource.\n * Supports glob-style patterns with any number of `*` wildcards; each `*`\n * matches zero or more characters of any kind (including `/`).\n * - \"prefix*\" matches anything starting with \"prefix\"\n * - \"*.example.com\" matches anything ending with \".example.com\"\n * - \"pre*suf\" matches anything with the given prefix and suffix\n * - \"a*b*c\" matches anything containing \"a\", then \"b\", then \"c\" in order\n * Without `*`, requires exact match.\n */\nexport function resourcePatternMatches(grantPattern: string, requiredResource: string): boolean {\n if (grantPattern === requiredResource) return true;\n if (!grantPattern.includes(\"*\")) return false;\n\n const parts = grantPattern.split(\"*\");\n const first = parts[0];\n const last = parts[parts.length - 1];\n\n if (!requiredResource.startsWith(first)) return false;\n if (!requiredResource.endsWith(last)) return false;\n\n let fixedLength = 0;\n for (const p of parts) fixedLength += p.length;\n if (requiredResource.length < fixedLength) return false;\n\n let searchStart = first.length;\n const searchEnd = requiredResource.length - last.length;\n for (let i = 1; i < parts.length - 1; i++) {\n const part = parts[i];\n if (part.length === 0) continue; // consecutive wildcards collapse\n const idx = requiredResource.indexOf(part, searchStart);\n if (idx === -1 || idx + part.length > searchEnd) return false;\n searchStart = idx + part.length;\n }\n\n return true;\n}\n\n/**\n * Check if a grant covers the resource requirements of an entitlement.\n *\n * Matching rules:\n * - Grant has no resources (broad) → covers any resource requirement\n * - Requirement has no resources (broad need) → only a broad grant covers it\n * - Both have resources → every required resource must match at least one grant pattern\n */\nexport function grantCoversResources(grant: EntitlementGrant, required: TaskEntitlement): boolean {\n // Broad grant covers everything\n if (grant.resources === undefined) return true;\n // Scoped grant cannot cover a broad requirement\n if (required.resources === undefined) return false;\n // Every required resource must be covered by at least one grant pattern\n return required.resources.every((req) =>\n grant.resources!.some((pat) => resourcePatternMatches(pat, req))\n );\n}\n\n/**\n * Merge two TaskEntitlements into a union (deduplicating by ID).\n * If the same ID appears in both, optional is false if either is false (most restrictive wins).\n * Resources are merged (union of all resource arrays for the same ID).\n */\nexport function mergeEntitlements(a: TaskEntitlements, b: TaskEntitlements): TaskEntitlements {\n if (a.entitlements.length === 0) return b;\n if (b.entitlements.length === 0) return a;\n\n const merged = new Map<EntitlementId, TaskEntitlement>();\n\n for (const entitlement of a.entitlements) {\n merged.set(entitlement.id, entitlement);\n }\n\n for (const entitlement of b.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n merged.set(entitlement.id, mergeEntitlementPair(existing, entitlement));\n } else {\n merged.set(entitlement.id, entitlement);\n }\n }\n\n return { entitlements: Array.from(merged.values()) };\n}\n\n/**\n * Merge two entitlements with the same ID.\n * - optional: false wins (most restrictive)\n * - reason: first non-empty wins\n * - resources: union\n */\nexport function mergeEntitlementPair(a: TaskEntitlement, b: TaskEntitlement): TaskEntitlement {\n const optional = (a.optional ?? false) && (b.optional ?? false) ? true : undefined;\n const reason = a.reason ?? b.reason;\n const resources = mergeResources(a.resources, b.resources);\n\n const result: TaskEntitlement = {\n id: a.id,\n ...(reason !== undefined && { reason }),\n ...(optional === true && { optional: true }),\n ...(resources !== undefined && { resources }),\n };\n return result;\n}\n\nexport function mergeResources(\n a: readonly string[] | undefined,\n b: readonly string[] | undefined\n): readonly string[] | undefined {\n // undefined means \"all resources\" (broad), so if either side is broad the merged result stays broad\n if (a === undefined || b === undefined) return undefined;\n const set = new Set([...a, ...b]);\n return Array.from(set);\n}\n",
|
|
8
8
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n type EntitlementId,\n type TaskEntitlement,\n type TaskEntitlements,\n type TrackedTaskEntitlement,\n type TrackedTaskEntitlements,\n EMPTY_ENTITLEMENTS,\n mergeEntitlementPair,\n} from \"../task/TaskEntitlements\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport { TaskStatus } from \"../task/TaskTypes\";\nimport type { TaskGraph } from \"./TaskGraph\";\n\n// ========================================================================\n// Options\n// ========================================================================\n\nexport interface GraphEntitlementOptions {\n /**\n * When true, annotate each entitlement with the source task IDs that require it.\n */\n readonly trackOrigins?: boolean;\n /**\n * Controls which ConditionalTask branches are included.\n * - \"all\" (default): Include entitlements from ALL branches (conservative, pre-execution analysis)\n * - \"active\": Only include entitlements from currently active branches (runtime, after conditions evaluated)\n */\n readonly conditionalBranches?: \"all\" | \"active\";\n}\n\n// ========================================================================\n// Graph Entitlement Computation\n// ========================================================================\n\n/**\n * Computes the aggregated entitlements for a TaskGraph.\n * Returns the union of all task entitlements in the graph.\n *\n * When `trackOrigins` is true, returns TrackedTaskEntitlements with source task IDs.\n */\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions & { readonly trackOrigins: true }\n): TrackedTaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements | TrackedTaskEntitlements {\n const tasks = graph.getTasks();\n if (tasks.length === 0) return EMPTY_ENTITLEMENTS;\n\n const trackOrigins = options?.trackOrigins ?? false;\n const conditionalBranches = options?.conditionalBranches ?? \"all\";\n\n // Accumulate entitlements by ID\n const merged = new Map<\n EntitlementId,\n { entitlement: TaskEntitlement; sourceTaskIds: TaskIdType[] }\n >();\n\n for (const task of tasks) {\n // For ConditionalTask with \"active\" mode, skip disabled tasks\n if (conditionalBranches === \"active\" && task.status !== undefined) {\n if (task.status === TaskStatus.DISABLED) continue;\n }\n\n const taskEntitlements = task.entitlements();\n for (const entitlement of taskEntitlements.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n // Merge: optional=false wins, resources are unioned\n existing.entitlement = mergeEntitlementPair(existing.entitlement, entitlement);\n if (trackOrigins) {\n existing.sourceTaskIds.push(task.id);\n }\n } else {\n merged.set(entitlement.id, {\n entitlement,\n sourceTaskIds: trackOrigins ? [task.id] : [],\n });\n }\n }\n }\n\n if (merged.size === 0) return EMPTY_ENTITLEMENTS;\n\n if (trackOrigins) {\n const entitlements: TrackedTaskEntitlement[] = [];\n for (const { entitlement, sourceTaskIds } of merged.values()) {\n entitlements.push({ ...entitlement, sourceTaskIds });\n }\n return { entitlements };\n }\n\n return { entitlements: Array.from(merged.values()).map((e) => e.entitlement) };\n}\n",
|
|
9
9
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport type { ServiceRegistry } from \"@workglow/util\";\nimport { getInputResolvers } from \"@workglow/util\";\n\n/**\n * Configuration for the input resolver\n */\nexport interface InputResolverConfig {\n readonly registry: ServiceRegistry;\n}\n\n/**\n * Extracts the format string from a schema, handling oneOf/anyOf wrappers.\n */\nexport function getSchemaFormat(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): string | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct format\n if (typeof s.format === \"string\") return s.format;\n\n // Check oneOf/anyOf/allOf for format\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (typeof v.format === \"string\") return v.format;\n }\n }\n }\n\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const fmt = getSchemaFormat(sub, visited);\n if (fmt !== undefined) return fmt;\n }\n }\n\n return undefined;\n}\n\n/**\n * Extracts the object-typed schema from a property schema, handling oneOf/anyOf wrappers.\n * This is needed for patterns like `oneOf: [{ type: \"string\" }, { type: \"object\", properties: {...} }]`\n * where the model can be either a string ID or an inline config object.\n */\nexport function getObjectSchema(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): (Record<string, unknown> & { properties: Record<string, unknown> }) | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct object schema with properties\n if (s.type === \"object\" && s.properties && typeof s.properties === \"object\") {\n return s as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n\n // Check oneOf/anyOf for object variant\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (v.type === \"object\" && v.properties && typeof v.properties === \"object\") {\n return v as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n }\n }\n }\n\n // Check allOf for object variant\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const result = getObjectSchema(sub, visited);\n if (result !== undefined) return result;\n }\n }\n\n return undefined;\n}\n\n/**\n * Gets the format prefix from a format string.\n * For \"model:TextEmbedding\" returns \"model\"\n * For \"storage:tabular\" returns \"storage\"\n */\nexport function getFormatPrefix(format: string): string {\n const colonIndex = format.indexOf(\":\");\n return colonIndex >= 0 ? format.substring(0, colonIndex) : format;\n}\n\n/**\n * Returns true if the schema has any properties with format annotations\n * (direct or in oneOf/anyOf variants). Used as a fast-path check to skip\n * resolution when no format-annotated properties exist.\n */\nexport function schemaHasFormatAnnotations(schema: DataPortSchema): boolean {\n if (typeof schema === \"boolean\") return false;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return false;\n\n for (const propSchema of Object.values(properties)) {\n if (getSchemaFormat(propSchema) !== undefined) return true;\n }\n return false;\n}\n\n/**\n * Resolves schema-annotated inputs by looking up string IDs from registries.\n * String values with matching format annotations are resolved to their instances.\n * Non-string values (objects/instances) are passed through unchanged.\n *\n * @param input The task input object\n * @param schema The task's input schema\n * @param config Configuration including the service registry\n * @returns The input with resolved values\n *\n * @example\n * ```typescript\n * // In TaskRunner.run()\n * const resolvedInput = await resolveSchemaInputs(\n * this.task.runInputData,\n * (this.task.constructor as typeof Task).inputSchema(),\n * { registry: this.registry }\n * );\n * ```\n */\nexport async function resolveSchemaInputs<T extends Record<string, unknown>>(\n input: T,\n schema: DataPortSchema,\n config: InputResolverConfig,\n visited: Set<object> = new Set()\n): Promise<T> {\n if (typeof schema === \"boolean\") return input;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return input;\n\n const resolvers = getInputResolvers();\n const resolved: Record<string, unknown> = { ...input };\n\n for (const [key, propSchema] of Object.entries(properties)) {\n let value = resolved[key];\n\n // Phase 1: Resolve format-annotated string values\n const format = getSchemaFormat(propSchema);\n if (format) {\n let resolver = resolvers.get(format);\n if (!resolver) {\n const prefix = getFormatPrefix(format);\n resolver = resolvers.get(prefix);\n }\n\n if (resolver) {\n // Handle string values\n if (typeof value === \"string\") {\n value = await resolver(value, format, config.registry);\n resolved[key] = value;\n }\n // Handle arrays - resolve string elements and pass through non-string elements unchanged\n else if (Array.isArray(value) && value.some((item) => typeof item === \"string\")) {\n const results = await Promise.all(\n value.map((item) =>\n typeof item === \"string\" ? resolver(item, format, config.registry) : item\n )\n );\n value = results.filter((result) => result !== undefined);\n resolved[key] = value;\n }\n }\n }\n\n // Phase 2: Recurse into object values if the schema defines nested properties\n if (\n value !== null &&\n value !== undefined &&\n typeof value === \"object\" &&\n !Array.isArray(value)\n ) {\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && !visited.has(objectSchema)) {\n visited.add(objectSchema);\n try {\n resolved[key] = await resolveSchemaInputs(\n value as Record<string, unknown>,\n objectSchema as DataPortSchema,\n config,\n visited\n );\n } finally {\n visited.delete(objectSchema);\n }\n }\n }\n }\n\n return resolved as T;\n}\n",
|
|
10
|
-
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport
|
|
10
|
+
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getObjectSchema, getSchemaFormat } from \"../task/InputResolver\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\n\n/**\n * Result of scanning a task graph for credential format annotations.\n */\nexport interface GraphFormatScanResult {\n /** Whether any task in the graph has a `format: \"credential\"` property in its input or config schema. */\n readonly needsCredentials: boolean;\n /** The set of format strings found (e.g., `\"credential\"`). */\n readonly credentialFormats: ReadonlySet<string>;\n}\n\n/**\n * Recursively walks a JSON Schema's properties looking for any property whose\n * format annotation matches `targetFormat`. Handles nested objects and\n * `oneOf`/`anyOf` wrappers.\n */\nfunction schemaHasFormat(schema: unknown, targetFormat: string): boolean {\n if (typeof schema !== \"object\" || schema === null) return false;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (properties && typeof properties === \"object\") {\n for (const propSchema of Object.values(properties)) {\n const format = getSchemaFormat(propSchema);\n if (format === targetFormat) return true;\n\n // Recurse into nested object schemas\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && schemaHasFormat(objectSchema, targetFormat)) return true;\n }\n }\n\n return false;\n}\n\n/**\n * Scans a task graph for any task whose input or config schema contains a\n * property with the given format annotation.\n *\n * @param graph The task graph to scan\n * @param targetFormat The format string to search for (e.g., `\"credential\"`)\n * @returns `true` if at least one task has a matching format annotation\n */\nexport function scanGraphForFormat(graph: ITaskGraph, targetFormat: string): boolean {\n for (const task of graph.getTasks()) {\n const inputSchema = task.inputSchema();\n if (typeof inputSchema !== \"boolean\" && schemaHasFormat(inputSchema, targetFormat)) {\n return true;\n }\n\n const configSchema = task.configSchema();\n if (typeof configSchema !== \"boolean\" && schemaHasFormat(configSchema, targetFormat)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Scans a task graph for credential requirements.\n *\n * A task only counts as needing credentials when it has a schema property\n * annotated with `format: \"credential\"` **and** the corresponding value is\n * actually set on the task's config or input defaults (non-empty string).\n * Annotating a schema is not enough — plenty of model configs have\n * `provider_config.credential_key` available but unused (e.g. local ONNX\n * models).\n *\n * @example\n * ```ts\n * const result = scanGraphForCredentials(graph);\n * if (result.needsCredentials) {\n * await ensureCredentialStoreUnlocked();\n * }\n * ```\n */\nexport function scanGraphForCredentials(graph: ITaskGraph): GraphFormatScanResult {\n const credentialFormats = new Set<string>();\n\n for (const task of graph.getTasks()) {\n collectUsedCredentialFormats(task.inputSchema(), task.defaults ?? {}, credentialFormats);\n collectUsedCredentialFormats(\n task.configSchema(),\n (task as unknown as { config?: Record<string, unknown> }).config ?? {},\n credentialFormats\n );\n }\n\n return {\n needsCredentials: credentialFormats.size > 0,\n credentialFormats,\n };\n}\n\n/**\n * Walk schema and data in parallel. When a property is annotated with a\n * credential format AND the corresponding data value is a non-empty string,\n * record the format. Recurses into nested object schemas.\n */\nfunction collectUsedCredentialFormats(schema: unknown, data: unknown, formats: Set<string>): void {\n if (typeof schema === \"boolean\" || typeof schema !== \"object\" || schema === null) return;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (!properties || typeof properties !== \"object\") return;\n\n const dataObj =\n typeof data === \"object\" && data !== null ? (data as Record<string, unknown>) : {};\n\n for (const [propName, propSchema] of Object.entries(properties)) {\n const format = getSchemaFormat(propSchema);\n const value = dataObj[propName];\n if (format === \"credential\" && typeof value === \"string\" && value.length > 0) {\n formats.add(format);\n }\n\n // Recurse into nested object schemas with the matching nested data\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema) {\n collectUsedCredentialFormats(objectSchema, value, formats);\n }\n }\n}\n",
|
|
11
11
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport { uuid4 } from \"@workglow/util\";\nimport { DATAFLOW_ALL_PORTS } from \"./Dataflow\";\nimport type { TaskGraph } from \"./TaskGraph\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport type {\n DataflowJson,\n JsonTaskItem,\n TaskGraphItemJson,\n TaskGraphJson,\n} from \"../task/TaskJSON\";\n\nexport interface GraphSchemaOptions {\n /**\n * When true, annotate each property with `x-source-task-id` or `x-source-task-ids`\n * to identify which task(s) the property originates from.\n */\n readonly trackOrigins?: boolean;\n}\n\n/**\n * Calculates the depth (longest path from any starting node) for each task in the graph.\n * @returns A map of task IDs to their depths\n */\nexport function calculateNodeDepths(graph: TaskGraph): Map<TaskIdType, number> {\n const depths = new Map<TaskIdType, number>();\n const tasks = graph.getTasks();\n\n for (const task of tasks) {\n depths.set(task.id, 0);\n }\n\n const sortedTasks = graph.topologicallySortedNodes();\n\n for (const task of sortedTasks) {\n const currentDepth = depths.get(task.id) || 0;\n const targetTasks = graph.getTargetTasks(task.id);\n\n for (const targetTask of targetTasks) {\n const targetDepth = depths.get(targetTask.id) || 0;\n depths.set(targetTask.id, Math.max(targetDepth, currentDepth + 1));\n }\n }\n\n return depths;\n}\n\n/**\n * Computes the input schema for a graph by examining root tasks (no incoming edges)\n * and non-root tasks with unsatisfied required inputs.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphInputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n const tasks = graph.getTasks();\n const startingNodes = tasks.filter((task) => graph.getSourceDataflows(task.id).length === 0);\n\n // Collect all properties from root tasks\n for (const task of startingNodes) {\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") {\n if (taskInputSchema === false) {\n continue;\n }\n if (taskInputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskInputSchema.properties || {};\n\n for (const [inputName, inputProp] of Object.entries(taskProperties)) {\n if (!properties[inputName]) {\n properties[inputName] = inputProp;\n\n if (taskInputSchema.required && taskInputSchema.required.includes(inputName)) {\n required.push(inputName);\n }\n\n if (trackOrigins) {\n propertyOrigins[inputName] = [task.id];\n }\n } else if (trackOrigins) {\n propertyOrigins[inputName].push(task.id);\n }\n }\n }\n\n // For non-root tasks, collect only REQUIRED properties not satisfied by dataflows.\n const sourceIds = new Set(startingNodes.map((t) => t.id));\n for (const task of tasks) {\n if (sourceIds.has(task.id)) continue;\n\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") continue;\n\n const requiredKeys = new Set<string>((taskInputSchema.required as string[] | undefined) || []);\n if (requiredKeys.size === 0) continue;\n\n const connectedPorts = new Set(\n graph.getSourceDataflows(task.id).map((df) => df.targetTaskPortId)\n );\n\n for (const key of requiredKeys) {\n if (connectedPorts.has(key)) continue;\n if (properties[key]) {\n // Property already collected — track additional origin\n if (trackOrigins) {\n propertyOrigins[key].push(task.id);\n }\n continue;\n }\n\n // Skip if the task already has a default value for this property\n if (task.defaults && task.defaults[key] !== undefined) continue;\n\n const prop = (taskInputSchema.properties || {})[key];\n if (!prop || typeof prop === \"boolean\") continue;\n\n properties[key] = prop;\n if (!required.includes(key)) {\n required.push(key);\n }\n\n if (trackOrigins) {\n propertyOrigins[key] = [task.id];\n }\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as const satisfies DataPortSchema;\n}\n\n/**\n * Computes the output schema for a graph by examining leaf tasks (no outgoing edges)\n * at the maximum depth level.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphOutputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n // Find all ending nodes (nodes with no outgoing dataflows)\n const tasks = graph.getTasks();\n const endingNodes = tasks.filter((task) => graph.getTargetDataflows(task.id).length === 0);\n\n // Calculate depths for all nodes\n const depths = calculateNodeDepths(graph);\n\n // Find the maximum depth among ending nodes\n const maxDepth = Math.max(...endingNodes.map((task) => depths.get(task.id) || 0));\n\n // Filter ending nodes to only those at the maximum depth (last level)\n const lastLevelNodes = endingNodes.filter((task) => depths.get(task.id) === maxDepth);\n\n // Count how many ending nodes produce each property\n const propertyCount: Record<string, number> = {};\n const propertySchema: Record<string, any> = {};\n\n for (const task of lastLevelNodes) {\n const taskOutputSchema = task.outputSchema();\n if (typeof taskOutputSchema === \"boolean\") {\n if (taskOutputSchema === false) {\n continue;\n }\n if (taskOutputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskOutputSchema.properties || {};\n\n for (const [outputName, outputProp] of Object.entries(taskProperties)) {\n propertyCount[outputName] = (propertyCount[outputName] || 0) + 1;\n if (!propertySchema[outputName]) {\n propertySchema[outputName] = outputProp;\n }\n if (trackOrigins) {\n if (!propertyOrigins[outputName]) {\n propertyOrigins[outputName] = [task.id];\n } else {\n propertyOrigins[outputName].push(task.id);\n }\n }\n }\n }\n\n // Build the final schema: properties produced by multiple nodes become arrays\n for (const [outputName] of Object.entries(propertyCount)) {\n const outputProp = propertySchema[outputName];\n\n if (lastLevelNodes.length === 1) {\n properties[outputName] = outputProp;\n } else {\n properties[outputName] = {\n type: \"array\",\n items: outputProp as any,\n };\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as DataPortSchema;\n}\n\n// ========================================================================\n// Boundary Node Injection\n// ========================================================================\n\n/**\n * Strips `x-source-task-id` and `x-source-task-ids` annotations from schema properties.\n */\nfunction stripOriginAnnotations(schema: DataPortSchema): DataPortSchema {\n if (typeof schema === \"boolean\" || !schema || typeof schema !== \"object\") return schema;\n const properties = schema.properties;\n if (!properties) return schema;\n\n const strippedProperties: Record<string, any> = {};\n for (const [key, prop] of Object.entries(properties)) {\n if (!prop || typeof prop !== \"object\") {\n strippedProperties[key] = prop;\n continue;\n }\n const {\n \"x-source-task-id\": _id,\n \"x-source-task-ids\": _ids,\n ...rest\n } = prop as Record<string, any>;\n strippedProperties[key] = rest;\n }\n\n return { ...schema, properties: strippedProperties } as DataPortSchema;\n}\n\n/**\n * Extracts origin task IDs from a schema property's `x-source-task-id` or `x-source-task-ids`.\n */\nfunction getOriginTaskIds(prop: Record<string, any>): TaskIdType[] {\n if (prop[\"x-source-task-ids\"]) {\n return prop[\"x-source-task-ids\"] as TaskIdType[];\n }\n if (prop[\"x-source-task-id\"] !== undefined) {\n return [prop[\"x-source-task-id\"] as TaskIdType];\n }\n return [];\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a TaskGraphJson.\n * The boundary nodes represent the graph's external interface.\n *\n * InputTask is placed first in the tasks array, OutputTask last.\n * Per-property dataflows connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToGraphJson(json: TaskGraphJson, graph: TaskGraph): TaskGraphJson {\n const hasInputTask = json.tasks.some((t) => t.type === \"InputTask\");\n const hasOutputTask = json.tasks.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return json;\n }\n\n const inputSchema = !hasInputTask\n ? computeGraphInputSchema(graph, { trackOrigins: true })\n : undefined;\n const outputSchema = !hasOutputTask\n ? computeGraphOutputSchema(graph, { trackOrigins: true })\n : undefined;\n\n const prependTasks: TaskGraphItemJson[] = [];\n const appendTasks: TaskGraphItemJson[] = [];\n const inputDataflows: DataflowJson[] = [];\n const outputDataflows: DataflowJson[] = [];\n\n if (!hasInputTask && inputSchema) {\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependTasks.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Create per-property dataflows from InputTask to origin tasks\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n inputDataflows.push({\n sourceTaskId: inputTaskId,\n sourceTaskPortId: propName,\n targetTaskId: originId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n if (!hasOutputTask && outputSchema) {\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n appendTasks.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n });\n\n // Create per-property dataflows from origin tasks to OutputTask\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n outputDataflows.push({\n sourceTaskId: originId,\n sourceTaskPortId: propName,\n targetTaskId: outputTaskId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n return {\n tasks: [...prependTasks, ...json.tasks, ...appendTasks],\n dataflows: [...inputDataflows, ...json.dataflows, ...outputDataflows],\n };\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a dependency JSON items array.\n * Per-property dependencies connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToDependencyJson(\n items: JsonTaskItem[],\n graph: TaskGraph\n): JsonTaskItem[] {\n const hasInputTask = items.some((t) => t.type === \"InputTask\");\n const hasOutputTask = items.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return items;\n }\n\n const prependItems: JsonTaskItem[] = [];\n const appendItems: JsonTaskItem[] = [];\n\n if (!hasInputTask) {\n const inputSchema = computeGraphInputSchema(graph, { trackOrigins: true });\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependItems.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Build dependencies for items that receive data from InputTask\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n const targetItem = items.find((item) => item.id === originId);\n if (!targetItem) continue;\n if (!targetItem.dependencies) {\n targetItem.dependencies = {};\n }\n const existing = targetItem.dependencies[propName];\n const dep = { id: inputTaskId, output: propName };\n if (!existing) {\n targetItem.dependencies[propName] = dep;\n } else if (Array.isArray(existing)) {\n existing.push(dep);\n } else {\n targetItem.dependencies[propName] = [existing, dep];\n }\n }\n }\n }\n }\n\n if (!hasOutputTask) {\n const outputSchema = computeGraphOutputSchema(graph, { trackOrigins: true });\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n // Build dependencies for OutputTask from origin tasks\n const outputDependencies: JsonTaskItem[\"dependencies\"] = {};\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n if (origins.length === 1) {\n outputDependencies[propName] = { id: origins[0], output: propName };\n } else if (origins.length > 1) {\n outputDependencies[propName] = origins.map((id) => ({ id, output: propName }));\n }\n }\n }\n\n appendItems.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n ...(Object.keys(outputDependencies).length > 0 ? { dependencies: outputDependencies } : {}),\n });\n }\n\n return [...prependItems, ...items, ...appendItems];\n}\n",
|
|
12
12
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { EventEmitter, ServiceRegistry, uuid4 } from \"@workglow/util\";\nimport type { ResourceScope } from \"@workglow/util\";\nimport { DirectedAcyclicGraph } from \"@workglow/util/graph\";\nimport { TaskOutputRepository } from \"../storage/TaskOutputRepository\";\nimport type { ITask } from \"../task/ITask\";\nimport type { StreamEvent } from \"../task/StreamTypes\";\nimport type { TaskEntitlements } from \"../task/TaskEntitlements\";\nimport type { JsonTaskItem, TaskGraphJson, TaskGraphJsonOptions } from \"../task/TaskJSON\";\nimport type { TaskIdType, TaskInput, TaskOutput, TaskStatus } from \"../task/TaskTypes\";\nimport type { PipeFunction } from \"./Conversions\";\nimport { ensureTask } from \"./Conversions\";\nimport type { DataflowIdType } from \"./Dataflow\";\nimport { Dataflow } from \"./Dataflow\";\nimport { computeGraphEntitlements } from \"./GraphEntitlementUtils\";\nimport { addBoundaryNodesToDependencyJson, addBoundaryNodesToGraphJson } from \"./GraphSchemaUtils\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\nimport {\n EventTaskGraphToDagMapping,\n GraphEventDagEvents,\n GraphEventDagParameters,\n TaskGraphEventListener,\n TaskGraphEvents,\n TaskGraphEventStatusParameters,\n TaskGraphStatusEvents,\n TaskGraphStatusListeners,\n} from \"./TaskGraphEvents\";\nimport type { GraphResultArray } from \"./TaskGraphRunner\";\nimport { CompoundMergeStrategy, GraphResult, TaskGraphRunner } from \"./TaskGraphRunner\";\n\n/**\n * Configuration for running a task graph\n */\nexport interface TaskGraphRunConfig {\n /** Optional output cache to use for this task graph */\n outputCache?: TaskOutputRepository | boolean;\n /** Optional signal to abort the task graph */\n parentSignal?: AbortSignal;\n /** Optional service registry to use for this task graph (creates child from global if not provided) */\n registry?: ServiceRegistry;\n /**\n * When true, streaming leaf tasks (no outgoing edges) accumulate their full\n * output so the workflow return value is complete. Defaults to true.\n * Pass false for subgraph runs where the parent handles streaming via\n * subscriptions and does not rely on the return value for stream data.\n */\n accumulateLeafOutputs?: boolean;\n /**\n * Maximum time in milliseconds for the entire graph execution.\n * When exceeded, all in-progress tasks are aborted and a TaskTimeoutError is thrown.\n */\n timeout?: number;\n /**\n * Maximum number of tasks allowed in the graph. Validated before execution starts.\n * Defaults to no limit. Set this to prevent runaway graph construction.\n */\n maxTasks?: number;\n /**\n * When true, check entitlements via the registered IEntitlementEnforcer before\n * graph execution begins. Throws TaskEntitlementError if any required (non-optional)\n * entitlements are denied. Default: false.\n */\n enforceEntitlements?: boolean;\n /**\n * Resource scope for collecting heavyweight resource disposers during graph execution.\n * Threaded to all tasks via IExecuteContext. The caller controls disposal.\n */\n resourceScope?: ResourceScope;\n}\n\nexport interface TaskGraphRunReactiveConfig extends Omit<\n TaskGraphRunConfig,\n \"enforceEntitlements\" | \"timeout\"\n> {\n /** Optional service registry to use for this task graph */\n registry?: ServiceRegistry;\n}\n\nclass TaskGraphDAG extends DirectedAcyclicGraph<\n ITask<any, any, any>,\n Dataflow,\n TaskIdType,\n DataflowIdType\n> {\n constructor() {\n super(\n (task: ITask<any, any, any>) => task.id,\n (dataflow: Dataflow) => dataflow.id\n );\n }\n}\n\ninterface TaskGraphConstructorConfig {\n outputCache?: TaskOutputRepository;\n dag?: TaskGraphDAG;\n}\n\n/**\n * Represents a task graph, a directed acyclic graph of tasks and data flows\n */\nexport class TaskGraph implements ITaskGraph {\n /** Optional output cache to use for this task graph */\n public outputCache?: TaskOutputRepository;\n\n /**\n * Constructor for TaskGraph\n * @param config Configuration for the task graph\n */\n constructor({ outputCache, dag }: TaskGraphConstructorConfig = {}) {\n this.outputCache = outputCache;\n this._dag = dag || new TaskGraphDAG();\n }\n\n private _dag: TaskGraphDAG;\n\n private _runner: TaskGraphRunner | undefined;\n public get runner(): TaskGraphRunner {\n if (!this._runner) {\n this._runner = new TaskGraphRunner(this, this.outputCache);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Public methods\n // ========================================================================\n\n /**\n * Runs the task graph\n * @param config Configuration for the graph run\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public run<ExecuteOutput extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<ExecuteOutput>> {\n return this.runner.runGraph<ExecuteOutput>(input, {\n outputCache: config?.outputCache || this.outputCache,\n parentSignal: config?.parentSignal || undefined,\n accumulateLeafOutputs: config?.accumulateLeafOutputs,\n registry: config?.registry,\n timeout: config?.timeout,\n maxTasks: config?.maxTasks,\n resourceScope: config?.resourceScope,\n });\n }\n\n /**\n * Runs the task graph reactively\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public runReactive<Output extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<Output>> {\n return this.runner.runGraphReactive<Output>(input, config);\n }\n\n /**\n * Merges the execute output to the run output\n * @param results The execute output\n * @param compoundMerge The compound merge strategy to use\n * @returns The run output\n */\n\n public mergeExecuteOutputsToRunOutput<\n ExecuteOutput extends TaskOutput,\n Merge extends CompoundMergeStrategy = CompoundMergeStrategy,\n >(\n results: GraphResultArray<ExecuteOutput>,\n compoundMerge: Merge\n ): GraphResult<ExecuteOutput, Merge> {\n return this.runner.mergeExecuteOutputsToRunOutput(results, compoundMerge);\n }\n\n /**\n * Aborts the task graph\n */\n public abort() {\n this.runner.abort();\n }\n\n /**\n * Disables the task graph\n */\n public async disable() {\n await this.runner.disable();\n }\n\n /**\n * Retrieves a task from the task graph by its id\n * @param id The id of the task to retrieve\n * @returns The task with the given id, or undefined if not found\n */\n public getTask(id: TaskIdType): ITask<any, any, any> | undefined {\n return this._dag.getNode(id);\n }\n\n /**\n * Retrieves all tasks in the task graph\n * @returns An array of tasks in the task graph\n */\n public getTasks(): ITask<any, any, any>[] {\n return this._dag.getNodes();\n }\n\n /**\n * Retrieves all tasks in the task graph topologically sorted\n * @returns An array of tasks in the task graph topologically sorted\n */\n public topologicallySortedNodes(): ITask<any, any, any>[] {\n return this._dag.topologicallySortedNodes();\n }\n\n /**\n * Adds a task to the task graph\n * @param task The task to add\n * @returns The current task graph\n */\n public addTask(fn: PipeFunction<any, any>, config?: any): unknown;\n public addTask(task: ITask<any, any, any>): unknown;\n public addTask(task: ITask<any, any, any> | PipeFunction<any, any>, config?: any): unknown {\n return this._dag.addNode(ensureTask(task, config));\n }\n\n /**\n * Adds multiple tasks to the task graph\n * @param tasks The tasks to add\n * @returns The current task graph\n */\n public addTasks(tasks: PipeFunction<any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[] | PipeFunction<any, any>[]): unknown[] {\n return this._dag.addNodes(tasks.map(ensureTask));\n }\n\n /**\n * Adds a data flow to the task graph\n * @param dataflow The data flow to add\n * @returns The current task graph\n */\n public addDataflow(dataflow: Dataflow) {\n return this._dag.addEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow);\n }\n\n /**\n * Adds multiple data flows to the task graph\n * @param dataflows The data flows to add\n * @returns The current task graph\n */\n public addDataflows(dataflows: Dataflow[]) {\n const addedEdges = dataflows.map<[s: unknown, t: unknown, e: Dataflow]>((edge) => {\n return [edge.sourceTaskId, edge.targetTaskId, edge];\n });\n return this._dag.addEdges(addedEdges);\n }\n\n /**\n * Retrieves a data flow from the task graph by its id\n * @param id The id of the data flow to retrieve\n * @returns The data flow with the given id, or undefined if not found\n */\n public getDataflow(id: DataflowIdType): Dataflow | undefined {\n for (const [, , edge] of this._dag.getEdges()) {\n if (edge.id === id) {\n return edge;\n }\n }\n return undefined;\n }\n\n /**\n * Retrieves all data flows in the task graph\n * @returns An array of data flows in the task graph\n */\n public getDataflows(): Dataflow[] {\n return this._dag.getEdges().map((edge) => edge[2]);\n }\n\n /**\n * Removes a data flow from the task graph\n * @param dataflow The data flow to remove\n * @returns The current task graph\n */\n public removeDataflow(dataflow: Dataflow) {\n return this._dag.removeEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow.id);\n }\n\n /**\n * Retrieves the data flows that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of data flows that are sources of the given task\n */\n public getSourceDataflows(taskId: unknown): Dataflow[] {\n return this._dag.inEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the data flows that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of data flows that are targets of the given task\n */\n public getTargetDataflows(taskId: unknown): Dataflow[] {\n return this._dag.outEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the tasks that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of tasks that are sources of the given task\n */\n public getSourceTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getSourceDataflows(taskId).map((dataflow) => this.getTask(dataflow.sourceTaskId)!);\n }\n\n /**\n * Retrieves the tasks that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of tasks that are targets of the given task\n */\n public getTargetTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getTargetDataflows(taskId).map((dataflow) => this.getTask(dataflow.targetTaskId)!);\n }\n\n /**\n * Removes a task from the task graph\n * @param taskId The id of the task to remove\n * @returns The current task graph\n */\n public removeTask(taskId: unknown) {\n return this._dag.removeNode(taskId);\n }\n\n public resetGraph() {\n this.runner.resetGraph(this, uuid4());\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns A TaskGraphJson object representing the tasks and dataflows\n */\n public toJSON(options?: TaskGraphJsonOptions): TaskGraphJson {\n const tasks = this.getTasks().map((node) => node.toJSON(options));\n const dataflows = this.getDataflows().map((df) => df.toJSON());\n let json: TaskGraphJson = {\n tasks,\n dataflows,\n };\n if (options?.withBoundaryNodes) {\n json = addBoundaryNodesToGraphJson(json, this);\n }\n return json;\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns An array of JsonTaskItem objects, each representing a task and its dependencies\n */\n public toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem[] {\n const tasks = this.getTasks().flatMap((node) => node.toDependencyJSON(options));\n this.getDataflows().forEach((df) => {\n const target = tasks.find((node) => node.id === df.targetTaskId)!;\n if (!target.dependencies) {\n target.dependencies = {};\n }\n const targetDeps = target.dependencies[df.targetTaskPortId];\n if (!targetDeps) {\n target.dependencies[df.targetTaskPortId] = {\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n };\n } else {\n if (Array.isArray(targetDeps)) {\n targetDeps.push({\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n });\n } else {\n target.dependencies[df.targetTaskPortId] = [\n targetDeps,\n { id: df.sourceTaskId, output: df.sourceTaskPortId },\n ];\n }\n }\n });\n if (options?.withBoundaryNodes) {\n return addBoundaryNodesToDependencyJson(tasks, this);\n }\n return tasks;\n }\n\n // ========================================================================\n // Event handling\n // ========================================================================\n\n /**\n * Event emitter for task lifecycle events\n */\n public get events(): EventEmitter<TaskGraphStatusListeners> {\n if (!this._events) {\n this._events = new EventEmitter<TaskGraphStatusListeners>();\n }\n return this._events;\n }\n protected _events: EventEmitter<TaskGraphStatusListeners> | undefined;\n\n /**\n * Subscribes to an event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n * @returns a function to unsubscribe from the event\n */\n public subscribe<Event extends TaskGraphEvents>(\n name: Event,\n fn: TaskGraphEventListener<Event>\n ): () => void {\n this.on(name, fn);\n return () => this.off(name, fn);\n }\n\n /**\n * Subscribes to status changes on all tasks (existing and future)\n * @param callback - Function called when any task's status changes\n * @param callback.taskId - The ID of the task whose status changed\n * @param callback.status - The new status of the task\n * @returns a function to unsubscribe from all task status events\n */\n public subscribeToTaskStatus(\n callback: (taskId: TaskIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to progress updates on all tasks (existing and future)\n * @param callback - Function called when any task reports progress\n * @param callback.taskId - The ID of the task reporting progress\n * @param callback.progress - The progress value (0-100)\n * @param callback.message - Optional progress message\n * @param callback.args - Additional arguments passed with the progress update\n * @returns a function to unsubscribe from all task progress events\n */\n public subscribeToTaskProgress(\n callback: (taskId: TaskIdType, progress: number, message?: string, ...args: any[]) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to progress events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to status changes on all dataflows (existing and future)\n * @param callback - Function called when any dataflow's status changes\n * @param callback.dataflowId - The ID of the dataflow whose status changed\n * @param callback.status - The new status of the dataflow\n * @returns a function to unsubscribe from all dataflow status events\n */\n public subscribeToDataflowStatus(\n callback: (dataflowId: DataflowIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing dataflows\n const dataflows = this.getDataflows();\n dataflows.forEach((dataflow) => {\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleDataflowAdded = (dataflowId: DataflowIdType) => {\n const dataflow = this.getDataflow(dataflowId);\n if (!dataflow || typeof dataflow.subscribe !== \"function\") return;\n\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"dataflow_added\", handleDataflowAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to streaming events on the task graph.\n * Listens for task_stream_start, task_stream_chunk, and task_stream_end\n * events emitted by the TaskGraphRunner during streaming task execution.\n *\n * @param callbacks - Object with optional callbacks for each streaming event\n * @returns a function to unsubscribe from all streaming events\n */\n public subscribeToTaskStreaming(callbacks: {\n onStreamStart?: (taskId: TaskIdType) => void;\n onStreamChunk?: (taskId: TaskIdType, event: StreamEvent) => void;\n onStreamEnd?: (taskId: TaskIdType, output: Record<string, any>) => void;\n }): () => void {\n const unsubscribes: (() => void)[] = [];\n\n if (callbacks.onStreamStart) {\n const unsub = this.subscribe(\"task_stream_start\", callbacks.onStreamStart);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamChunk) {\n const unsub = this.subscribe(\"task_stream_chunk\", callbacks.onStreamChunk);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamEnd) {\n const unsub = this.subscribe(\"task_stream_end\", callbacks.onStreamEnd);\n unsubscribes.push(unsub);\n }\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to entitlement changes on all tasks (existing and future).\n * When any task's entitlements change, the graph recomputes and emits its own\n * `entitlementChange` event. Structural changes (task_added, task_removed) also trigger.\n *\n * @param callback - Function called with the aggregated entitlements whenever they change\n * @returns a function to unsubscribe from all entitlement events\n */\n public subscribeToTaskEntitlements(\n callback: (entitlements: TaskEntitlements) => void\n ): () => void {\n const globalUnsubs: (() => void)[] = [];\n const taskUnsubs = new Map<TaskIdType, () => void>();\n\n const emitChange = () => {\n const entitlements = computeGraphEntitlements(this);\n this.emit(\"entitlementChange\", entitlements);\n callback(entitlements);\n };\n\n const subscribeTask = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n const unsub = task.subscribe(\"entitlementChange\", () => emitChange());\n taskUnsubs.set(taskId, unsub);\n };\n\n // Subscribe to entitlementChange events on all existing tasks\n for (const task of this.getTasks()) {\n subscribeTask(task.id);\n }\n\n // Emit the initial state immediately so subscribers don't miss the current entitlements\n emitChange();\n\n // Subscribe to new tasks being added\n globalUnsubs.push(\n this.subscribe(\"task_added\", (taskId: TaskIdType) => {\n subscribeTask(taskId);\n emitChange();\n })\n );\n\n globalUnsubs.push(\n this.subscribe(\"task_removed\", (taskId: TaskIdType) => {\n const unsub = taskUnsubs.get(taskId);\n if (unsub) {\n unsub();\n taskUnsubs.delete(taskId);\n }\n emitChange();\n })\n );\n\n return () => {\n globalUnsubs.forEach((unsub) => unsub());\n taskUnsubs.forEach((unsub) => unsub());\n taskUnsubs.clear();\n };\n }\n\n /**\n * Registers an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n on<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.on(dagEvent, fn as Parameters<typeof this._dag.on>[1]);\n }\n return this.events.on(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Removes an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n off<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.off(dagEvent, fn as Parameters<typeof this._dag.off>[1]);\n }\n return this.events.off(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n emit<E extends GraphEventDagEvents>(name: E, ...args: GraphEventDagParameters<E>): void;\n emit<E extends TaskGraphStatusEvents>(name: E, ...args: TaskGraphEventStatusParameters<E>): void;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n emit(name: string, ...args: any[]): void {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe: overload signatures guarantee correct arg types at call sites\n return (this.emit_dag as Function).call(this, name, ...args);\n } else {\n return (this.emit_local as Function).call(this, name, ...args);\n }\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_local<Event extends TaskGraphStatusEvents>(\n name: Event,\n ...args: TaskGraphEventStatusParameters<Event>\n ) {\n return this.events?.emit(name, ...args);\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_dag<Event extends GraphEventDagEvents>(\n name: Event,\n ...args: GraphEventDagParameters<Event>\n ) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n // Safe cast: GraphEventDagParameters matches the DAG's emit parameters (both are ID-based)\n return this._dag.emit(dagEvent, ...(args as unknown as [unknown]));\n }\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nfunction serialGraphEdges(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): Dataflow[] {\n const edges: Dataflow[] = [];\n for (let i = 0; i < tasks.length - 1; i++) {\n edges.push(new Dataflow(tasks[i].id, inputHandle, tasks[i + 1].id, outputHandle));\n }\n return edges;\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nexport function serialGraph(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): TaskGraph {\n const graph = new TaskGraph();\n graph.addTasks(tasks);\n graph.addDataflows(serialGraphEdges(tasks, inputHandle, outputHandle));\n return graph;\n}\n",
|
|
13
13
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getLogger } from \"@workglow/util\";\nimport type { DataPortSchema, SchemaNode } from \"@workglow/util/schema\";\nimport { compileSchema } from \"@workglow/util/schema\";\nimport { computeGraphEntitlements } from \"../task-graph/GraphEntitlementUtils\";\nimport { computeGraphInputSchema, computeGraphOutputSchema } from \"../task-graph/GraphSchemaUtils\";\nimport { TaskGraph } from \"../task-graph/TaskGraph\";\nimport { CompoundMergeStrategy, PROPERTY_ARRAY } from \"../task-graph/TaskGraphRunner\";\nimport type { CreateLoopWorkflow } from \"../task-graph/Workflow\";\nimport { GraphAsTaskRunner } from \"./GraphAsTaskRunner\";\nimport type { IExecuteContext, IRunConfig } from \"./ITask\";\nimport type { StreamEvent, StreamFinish } from \"./StreamTypes\";\nimport { Task } from \"./Task\";\nimport type { TaskEntitlements } from \"./TaskEntitlements\";\nimport type { TaskEventListener, TaskEvents } from \"./TaskEvents\";\nimport type { JsonTaskItem, TaskGraphItemJson, TaskGraphJsonOptions } from \"./TaskJSON\";\nimport type { TaskConfig, TaskInput, TaskOutput, TaskTypeName } from \"./TaskTypes\";\nimport { TaskConfigSchema } from \"./TaskTypes\";\n\nexport const graphAsTaskConfigSchema = {\n type: \"object\",\n properties: {\n ...TaskConfigSchema[\"properties\"],\n compoundMerge: { type: \"string\", \"x-ui-hidden\": true },\n },\n additionalProperties: false,\n} as const satisfies DataPortSchema;\n\nexport type GraphAsTaskConfig<Input extends TaskInput = TaskInput> = TaskConfig<Input> & {\n /** subGraph is extracted in the constructor before validation — not in the JSON schema */\n subGraph?: TaskGraph;\n compoundMerge?: CompoundMergeStrategy;\n};\n\n/**\n * A task that contains a subgraph of tasks\n */\nexport class GraphAsTask<\n Input extends TaskInput = TaskInput,\n Output extends TaskOutput = TaskOutput,\n Config extends GraphAsTaskConfig<Input> = GraphAsTaskConfig<Input>,\n> extends Task<Input, Output, Config> {\n // ========================================================================\n // Static properties - should be overridden by subclasses\n // ========================================================================\n\n public static override type: TaskTypeName = \"GraphAsTask\";\n public static override title: string = \"Group\";\n public static override description: string = \"A group of tasks that are executed together\";\n public static override category: string = \"Flow Control\";\n public static compoundMerge: CompoundMergeStrategy = PROPERTY_ARRAY;\n\n /** This task has dynamic schemas that change based on the subgraph structure */\n public static override hasDynamicSchemas: boolean = true;\n\n /** Entitlements are always dynamic — they depend on child tasks in the subgraph */\n public static override hasDynamicEntitlements: boolean = true;\n\n // ========================================================================\n // Constructor\n // ========================================================================\n\n /**\n * @param config Task configuration; `subGraph` is applied to this instance and stripped before validating config.\n * @param runConfig Runtime configuration (forwarded to {@link Task}).\n */\n constructor(config: Partial<Config> = {}, runConfig: Partial<IRunConfig> = {}) {\n const { subGraph, ...rest } = config;\n super(rest as Partial<Config>, runConfig);\n if (subGraph) {\n this.subGraph = subGraph;\n }\n this.regenerateGraph();\n }\n\n // ========================================================================\n // TaskRunner delegation - Executes and manages the task\n // ========================================================================\n\n declare _runner: GraphAsTaskRunner<Input, Output, Config>;\n\n /**\n * Task runner for handling the task execution\n */\n override get runner(): GraphAsTaskRunner<Input, Output, Config> {\n if (!this._runner) {\n this._runner = new GraphAsTaskRunner<Input, Output, Config>(this);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Static to Instance conversion methods\n // ========================================================================\n\n public static override configSchema(): DataPortSchema {\n return graphAsTaskConfigSchema;\n }\n\n public get compoundMerge(): CompoundMergeStrategy {\n return this.config?.compoundMerge || (this.constructor as typeof GraphAsTask).compoundMerge;\n }\n\n public override get cacheable(): boolean {\n return (\n this.runConfig?.cacheable ??\n this.config?.cacheable ??\n ((this.constructor as typeof GraphAsTask).cacheable && !this.hasChildren())\n );\n }\n\n // ========================================================================\n // Input/Output handling\n // ========================================================================\n\n /**\n * Override inputSchema to compute it dynamically from the subgraph at runtime.\n * For root tasks (no incoming edges) all input properties are collected.\n * For non-root tasks, only REQUIRED properties that are not satisfied by\n * any internal dataflow are added — this ensures that required inputs are\n * included in the graph's input schema without pulling in every optional\n * downstream property.\n */\n public override inputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).inputSchema();\n }\n\n return computeGraphInputSchema(this.subGraph);\n }\n\n protected _inputSchemaNode: SchemaNode | undefined;\n /**\n * Gets the compiled input schema\n */\n protected override getInputSchemaNode(): SchemaNode {\n // every graph as task is different, so we need to compile the schema for each one\n if (!this._inputSchemaNode) {\n try {\n const dataPortSchema = this.inputSchema();\n const schemaNode = Task.generateInputSchemaNode(dataPortSchema);\n this._inputSchemaNode = schemaNode;\n } catch (error) {\n // If compilation fails, fall back to accepting any object structure.\n // This is a safety net for schemas that json-schema-library can't compile.\n getLogger().warn(\n `GraphAsTask \"${this.type}\" (${this.id}): Failed to compile input schema, ` +\n `falling back to permissive validation. Inputs will NOT be validated.`,\n { error, taskType: this.type, taskId: this.id }\n );\n this._inputSchemaNode = compileSchema({});\n }\n }\n return this._inputSchemaNode!;\n }\n\n /**\n\n * Override outputSchema to compute it dynamically from the subgraph at runtime\n * The output schema depends on the compoundMerge strategy and the nodes at the last level\n */\n\n public override outputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).outputSchema();\n }\n\n return computeGraphOutputSchema(this.subGraph);\n }\n\n /**\n * Override entitlements to aggregate from all tasks in the subgraph.\n */\n public override entitlements(): TaskEntitlements {\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).entitlements();\n }\n return computeGraphEntitlements(this.subGraph);\n }\n\n /**\n * Resets input data to defaults\n */\n public override resetInputData(): void {\n super.resetInputData();\n if (this.hasChildren()) {\n this.subGraph!.getTasks().forEach((node) => {\n node.resetInputData();\n });\n this.subGraph!.getDataflows().forEach((dataflow) => {\n dataflow.reset();\n });\n }\n }\n\n // ========================================================================\n // Streaming pass-through\n // ========================================================================\n\n /**\n * Stream pass-through for compound tasks: runs the subgraph and forwards\n * streaming events from ending nodes to the outer graph. Also re-yields\n * any input streams from upstream for cases where this GraphAsTask is\n * itself downstream of another streaming task.\n */\n async *executeStream(input: Input, context: IExecuteContext): AsyncIterable<StreamEvent<Output>> {\n // Forward upstream input streams first (pass-through from outer graph)\n if (context.inputStreams) {\n for (const [, stream] of context.inputStreams) {\n const reader = stream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value.type === \"finish\") continue;\n yield value as StreamEvent<Output>;\n }\n } finally {\n reader.releaseLock();\n }\n }\n }\n\n // Run the subgraph and forward streaming events from ending nodes\n if (this.hasChildren()) {\n const endingNodeIds = new Set<unknown>();\n const tasks = this.subGraph.getTasks();\n for (const task of tasks) {\n if (this.subGraph.getTargetDataflows(task.id).length === 0) {\n endingNodeIds.add(task.id);\n }\n }\n\n const eventQueue: StreamEvent<Output>[] = [];\n let subgraphDone = false;\n\n // Eager promise/resolver — always available for producers to signal.\n // Prevents a race where producers call a stale or undefined resolver,\n // causing the generator to hang on a promise that never resolves.\n // `isWaiting` is true only while the generator is suspended at `await notifyPromise`.\n // `hasPending` records a notification that arrived while the generator was active,\n // so the generator skips the next wait without allocating a new promise.\n let { promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>();\n let isWaiting = false;\n let hasPending = false;\n const notify = () => {\n if (isWaiting) {\n // Wake the generator and prepare a fresh deferred for the next wait.\n notifyResolve();\n ({ promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>());\n isWaiting = false;\n } else {\n // Generator is still draining; skip the allocation.\n hasPending = true;\n }\n };\n\n const unsub = this.subGraph.subscribeToTaskStreaming({\n onStreamChunk: (taskId, event) => {\n if (endingNodeIds.has(taskId) && event.type !== \"finish\") {\n eventQueue.push(event as StreamEvent<Output>);\n notify();\n }\n },\n });\n\n const runPromise = this.subGraph\n .run<Output>(input, { parentSignal: context.signal, accumulateLeafOutputs: false })\n .then(\n (results) => {\n subgraphDone = true;\n notify();\n return results;\n },\n (err) => {\n subgraphDone = true;\n notify();\n throw err;\n }\n );\n\n // Yield events as they arrive from ending nodes\n while (!subgraphDone) {\n if (eventQueue.length === 0) {\n if (hasPending) {\n // A notification arrived while we were active; consume it without blocking.\n hasPending = false;\n } else {\n isWaiting = true;\n await notifyPromise;\n }\n }\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n }\n // Drain any remaining events\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n\n unsub();\n\n const results = await runPromise;\n const mergedOutput = this.subGraph.mergeExecuteOutputsToRunOutput(\n results,\n this.compoundMerge\n ) as Output;\n yield { type: \"finish\", data: mergedOutput } as StreamFinish<Output>;\n } else {\n yield { type: \"finish\", data: input as unknown as Output } as StreamFinish<Output>;\n }\n }\n\n // ========================================================================\n // Compound task methods\n // ========================================================================\n\n /**\n * Regenerates the subtask graph and emits a \"regenerate\" event\n *\n * Subclasses should override this method to implement the actual graph\n * regeneration logic, but all they need to do is call this method to\n * emit the \"regenerate\" event.\n */\n public override regenerateGraph(): void {\n this._inputSchemaNode = undefined;\n this.events.emit(\"regenerate\");\n this.emitEntitlementChange();\n }\n\n // ========================================================================\n // SubGraph entitlement forwarding\n // ========================================================================\n\n /** Unsubscribe handle for the current subGraph entitlement subscription */\n private _entitlementUnsub: (() => void) | undefined;\n\n /**\n * Guards against re-entry while the synchronous initial emit of\n * `subscribeToTaskEntitlements` is unwinding. Without this, the initial\n * emit's callback re-reads `this.subGraph`, which would re-trigger\n * `_syncSubGraphEntitlementSubscription` before `_entitlementUnsub` has\n * been assigned and loop forever.\n */\n private _subscribingEntitlements: boolean = false;\n\n // ========================================================================\n // SubGraph entitlement subscription\n // ========================================================================\n\n /**\n * Subscribe to the subGraph's aggregated entitlement changes and forward\n * them as an entitlementChange event on this task so that the parent\n * TaskGraph / Workflow sees the update.\n */\n private _subscribeToSubGraphEntitlements(graph: TaskGraph): void {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n this._subscribingEntitlements = true;\n try {\n this._entitlementUnsub = graph.subscribeToTaskEntitlements(() => {\n this.emitEntitlementChange();\n });\n } finally {\n this._subscribingEntitlements = false;\n }\n }\n\n private _syncSubGraphEntitlementSubscription(\n graph: TaskGraph | undefined = this._subGraph\n ): void {\n if (this._subscribingEntitlements) return;\n\n if ((this._events?.listenerCount(\"entitlementChange\") ?? 0) === 0) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n return;\n }\n\n if (!graph || this._entitlementUnsub) {\n return;\n }\n\n this._subscribeToSubGraphEntitlements(graph);\n }\n\n public override subscribe<Event extends TaskEvents>(\n name: Event,\n fn: TaskEventListener<Event>\n ): () => void {\n const unsub = super.subscribe(name, fn);\n if (name !== \"entitlementChange\") {\n return unsub;\n }\n\n this._syncSubGraphEntitlementSubscription();\n\n return () => {\n unsub();\n this._syncSubGraphEntitlementSubscription();\n };\n }\n\n public override on<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.on(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override off<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.off(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override once<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.once(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override set subGraph(subGraph: TaskGraph) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n super.subGraph = subGraph;\n this._syncSubGraphEntitlementSubscription(subGraph);\n }\n\n override get subGraph(): TaskGraph {\n const graph = super.subGraph;\n // The base getter may have lazily created a new graph — subscribe only when needed.\n this._syncSubGraphEntitlementSubscription(graph);\n return graph;\n }\n\n // ========================================================================\n // Serialization methods\n // ========================================================================\n\n /**\n * Serializes the task and its subtasks into a format that can be stored\n * @returns The serialized task and subtasks\n */\n public override toJSON(options?: TaskGraphJsonOptions): TaskGraphItemJson {\n let json = super.toJSON(options);\n const hasChildren = this.hasChildren();\n if (hasChildren) {\n json = {\n ...json,\n merge: this.compoundMerge,\n subgraph: this.subGraph!.toJSON(options),\n };\n }\n return json;\n }\n\n /**\n * Converts the task to a JSON format suitable for dependency tracking\n * @returns The task and subtasks in JSON thats easier for humans to read\n */\n public override toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem {\n const json = this.toJSON(options);\n if (this.hasChildren()) {\n if (\"subgraph\" in json) {\n delete json.subgraph;\n }\n return { ...json, subtasks: this.subGraph!.toDependencyJSON(options) };\n }\n return json;\n }\n}\n\ndeclare module \"../task-graph/Workflow\" {\n interface Workflow {\n /**\n * Starts a group that wraps inner tasks in a GraphAsTask subgraph.\n * Use .endGroup() to close the group and return to the parent workflow.\n */\n group: CreateLoopWorkflow<TaskInput, TaskOutput, GraphAsTaskConfig<TaskInput>>;\n\n /**\n * Ends the group and returns to the parent workflow.\n */\n endGroup(): Workflow;\n }\n}\n\n// Prototype assignments live in Workflow.ts (bottom of file) to avoid\n// circular-dependency issues at module evaluation time.\n",
|
package/dist/bun.js.map
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { TaskIdType } from \"./TaskTypes\";\n\n// ========================================================================\n// Entitlement Types\n// ========================================================================\n\n/**\n * Hierarchical entitlement identifier.\n * Uses colon-separated namespacing: \"network\", \"network:http\", \"network:websocket\"\n * A grant of \"network\" implicitly covers \"network:http\" and \"network:websocket\".\n */\nexport type EntitlementId = string;\n\n/**\n * A single entitlement declaration.\n */\nexport interface TaskEntitlement {\n /** Hierarchical identifier, e.g. \"network:http\", \"credential:anthropic\", \"code-execution:javascript\" */\n readonly id: EntitlementId;\n /** Human-readable reason why this entitlement is needed */\n readonly reason?: string;\n /** Whether this entitlement is optional (task can degrade gracefully without it) */\n readonly optional?: boolean;\n /**\n * Specific resources this entitlement applies to.\n * E.g. URL patterns for network, model IDs for ai:model, server names for mcp.\n * When undefined, the entitlement applies broadly.\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Complete entitlement declaration for a task or graph.\n */\nexport interface TaskEntitlements {\n /** List of entitlements required */\n readonly entitlements: readonly TaskEntitlement[];\n}\n\n/**\n * An entitlement with origin tracking (which task(s) require it).\n */\nexport interface TrackedTaskEntitlement extends TaskEntitlement {\n /** Task IDs that require this entitlement */\n readonly sourceTaskIds: readonly TaskIdType[];\n}\n\n/**\n * Entitlements with optional origin tracking.\n */\nexport interface TrackedTaskEntitlements {\n readonly entitlements: readonly TrackedTaskEntitlement[];\n}\n\n// ========================================================================\n// Well-Known Entitlement Constants\n// ========================================================================\n\n/**\n * Well-known entitlement categories. Tasks may also use custom IDs beyond these.\n */\nexport const Entitlements = {\n // Network\n NETWORK: \"network\",\n NETWORK_HTTP: \"network:http\",\n NETWORK_WEBSOCKET: \"network:websocket\",\n NETWORK_PRIVATE: \"network:private\",\n\n // File system\n FILESYSTEM: \"filesystem\",\n FILESYSTEM_READ: \"filesystem:read\",\n FILESYSTEM_WRITE: \"filesystem:write\",\n\n // Code execution\n CODE_EXECUTION: \"code-execution\",\n CODE_EXECUTION_JS: \"code-execution:javascript\",\n\n // Credentials\n CREDENTIAL: \"credential\",\n\n // AI models\n AI: \"ai\",\n AI_MODEL: \"ai:model\",\n AI_INFERENCE: \"ai:inference\",\n\n // MCP\n MCP: \"mcp\",\n MCP_TOOL_CALL: \"mcp:tool-call\",\n MCP_RESOURCE_READ: \"mcp:resource-read\",\n MCP_PROMPT_GET: \"mcp:prompt-get\",\n MCP_STDIO: \"mcp:stdio\",\n\n // Storage / database\n STORAGE: \"storage\",\n STORAGE_READ: \"storage:read\",\n STORAGE_WRITE: \"storage:write\",\n\n // Browser automation\n BROWSER_CONTROL: \"browser\",\n BROWSER_CONTROL_LOCAL: \"browser:local\",\n BROWSER_CONTROL_CLOUD: \"browser:cloud\",\n BROWSER_CONTROL_NAVIGATE: \"browser:navigate\",\n BROWSER_CONTROL_EVALUATE: \"browser:evaluate\",\n BROWSER_CONTROL_CREDENTIAL: \"browser:credential\",\n} as const;\n\n// ========================================================================\n// Empty Entitlements Singleton\n// ========================================================================\n\n/** Shared empty entitlements object to avoid unnecessary allocations */\nexport const EMPTY_ENTITLEMENTS: TaskEntitlements = Object.freeze({\n entitlements: Object.freeze([]),\n});\n\n// ========================================================================\n// Utility Functions\n// ========================================================================\n\n/**\n * Check if a granted entitlement covers a required entitlement.\n * \"network\" covers \"network:http\" (parent covers child in hierarchy).\n */\nexport function entitlementCovers(granted: EntitlementId, required: EntitlementId): boolean {\n return required === granted || required.startsWith(granted + \":\");\n}\n\n/**\n * A grant declaration — what a consumer is willing to allow.\n * Unlike TaskEntitlement (which declares what a task *needs*), this declares what is *permitted*.\n */\nexport interface EntitlementGrant {\n /** Entitlement ID to grant. Hierarchy applies: granting \"network\" covers \"network:http\". */\n readonly id: EntitlementId;\n /**\n * Specific resources this grant covers.\n * - undefined → broad grant, covers all resources for this entitlement\n * - string[] → scoped grant, only covers requirements whose resources are a subset\n *\n * Supports glob-style patterns with any number of `*` wildcards.\n * Each `*` matches zero or more characters of any kind, including `/`.\n * - \"/tmp/*\" covers \"/tmp/data.json\" and \"/tmp/subdir/file.txt\"\n * - \"*.example.com\" covers \"api.example.com\"\n * - \"file-*-v*.json\" covers \"file-data-v2.json\"\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Check if a single grant resource pattern matches a single required resource.\n * Supports glob-style patterns with any number of `*` wildcards; each `*`\n * matches zero or more characters of any kind (including `/`).\n * - \"prefix*\" matches anything starting with \"prefix\"\n * - \"*.example.com\" matches anything ending with \".example.com\"\n * - \"pre*suf\" matches anything with the given prefix and suffix\n * - \"a*b*c\" matches anything containing \"a\", then \"b\", then \"c\" in order\n * Without `*`, requires exact match.\n */\nexport function resourcePatternMatches(grantPattern: string, requiredResource: string): boolean {\n if (grantPattern === requiredResource) return true;\n if (!grantPattern.includes(\"*\")) return false;\n\n const parts = grantPattern.split(\"*\");\n const first = parts[0];\n const last = parts[parts.length - 1];\n\n if (!requiredResource.startsWith(first)) return false;\n if (!requiredResource.endsWith(last)) return false;\n\n let fixedLength = 0;\n for (const p of parts) fixedLength += p.length;\n if (requiredResource.length < fixedLength) return false;\n\n let searchStart = first.length;\n const searchEnd = requiredResource.length - last.length;\n for (let i = 1; i < parts.length - 1; i++) {\n const part = parts[i];\n if (part.length === 0) continue; // consecutive wildcards collapse\n const idx = requiredResource.indexOf(part, searchStart);\n if (idx === -1 || idx + part.length > searchEnd) return false;\n searchStart = idx + part.length;\n }\n\n return true;\n}\n\n/**\n * Check if a grant covers the resource requirements of an entitlement.\n *\n * Matching rules:\n * - Grant has no resources (broad) → covers any resource requirement\n * - Requirement has no resources (broad need) → only a broad grant covers it\n * - Both have resources → every required resource must match at least one grant pattern\n */\nexport function grantCoversResources(grant: EntitlementGrant, required: TaskEntitlement): boolean {\n // Broad grant covers everything\n if (grant.resources === undefined) return true;\n // Scoped grant cannot cover a broad requirement\n if (required.resources === undefined) return false;\n // Every required resource must be covered by at least one grant pattern\n return required.resources.every((req) =>\n grant.resources!.some((pat) => resourcePatternMatches(pat, req))\n );\n}\n\n/**\n * Merge two TaskEntitlements into a union (deduplicating by ID).\n * If the same ID appears in both, optional is false if either is false (most restrictive wins).\n * Resources are merged (union of all resource arrays for the same ID).\n */\nexport function mergeEntitlements(a: TaskEntitlements, b: TaskEntitlements): TaskEntitlements {\n if (a.entitlements.length === 0) return b;\n if (b.entitlements.length === 0) return a;\n\n const merged = new Map<EntitlementId, TaskEntitlement>();\n\n for (const entitlement of a.entitlements) {\n merged.set(entitlement.id, entitlement);\n }\n\n for (const entitlement of b.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n merged.set(entitlement.id, mergeEntitlementPair(existing, entitlement));\n } else {\n merged.set(entitlement.id, entitlement);\n }\n }\n\n return { entitlements: Array.from(merged.values()) };\n}\n\n/**\n * Merge two entitlements with the same ID.\n * - optional: false wins (most restrictive)\n * - reason: first non-empty wins\n * - resources: union\n */\nexport function mergeEntitlementPair(a: TaskEntitlement, b: TaskEntitlement): TaskEntitlement {\n const optional = (a.optional ?? false) && (b.optional ?? false) ? true : undefined;\n const reason = a.reason ?? b.reason;\n const resources = mergeResources(a.resources, b.resources);\n\n const result: TaskEntitlement = {\n id: a.id,\n ...(reason !== undefined && { reason }),\n ...(optional === true && { optional: true }),\n ...(resources !== undefined && { resources }),\n };\n return result;\n}\n\nexport function mergeResources(\n a: readonly string[] | undefined,\n b: readonly string[] | undefined\n): readonly string[] | undefined {\n // undefined means \"all resources\" (broad), so if either side is broad the merged result stays broad\n if (a === undefined || b === undefined) return undefined;\n const set = new Set([...a, ...b]);\n return Array.from(set);\n}\n",
|
|
8
8
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n type EntitlementId,\n type TaskEntitlement,\n type TaskEntitlements,\n type TrackedTaskEntitlement,\n type TrackedTaskEntitlements,\n EMPTY_ENTITLEMENTS,\n mergeEntitlementPair,\n} from \"../task/TaskEntitlements\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport { TaskStatus } from \"../task/TaskTypes\";\nimport type { TaskGraph } from \"./TaskGraph\";\n\n// ========================================================================\n// Options\n// ========================================================================\n\nexport interface GraphEntitlementOptions {\n /**\n * When true, annotate each entitlement with the source task IDs that require it.\n */\n readonly trackOrigins?: boolean;\n /**\n * Controls which ConditionalTask branches are included.\n * - \"all\" (default): Include entitlements from ALL branches (conservative, pre-execution analysis)\n * - \"active\": Only include entitlements from currently active branches (runtime, after conditions evaluated)\n */\n readonly conditionalBranches?: \"all\" | \"active\";\n}\n\n// ========================================================================\n// Graph Entitlement Computation\n// ========================================================================\n\n/**\n * Computes the aggregated entitlements for a TaskGraph.\n * Returns the union of all task entitlements in the graph.\n *\n * When `trackOrigins` is true, returns TrackedTaskEntitlements with source task IDs.\n */\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions & { readonly trackOrigins: true }\n): TrackedTaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements | TrackedTaskEntitlements {\n const tasks = graph.getTasks();\n if (tasks.length === 0) return EMPTY_ENTITLEMENTS;\n\n const trackOrigins = options?.trackOrigins ?? false;\n const conditionalBranches = options?.conditionalBranches ?? \"all\";\n\n // Accumulate entitlements by ID\n const merged = new Map<\n EntitlementId,\n { entitlement: TaskEntitlement; sourceTaskIds: TaskIdType[] }\n >();\n\n for (const task of tasks) {\n // For ConditionalTask with \"active\" mode, skip disabled tasks\n if (conditionalBranches === \"active\" && task.status !== undefined) {\n if (task.status === TaskStatus.DISABLED) continue;\n }\n\n const taskEntitlements = task.entitlements();\n for (const entitlement of taskEntitlements.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n // Merge: optional=false wins, resources are unioned\n existing.entitlement = mergeEntitlementPair(existing.entitlement, entitlement);\n if (trackOrigins) {\n existing.sourceTaskIds.push(task.id);\n }\n } else {\n merged.set(entitlement.id, {\n entitlement,\n sourceTaskIds: trackOrigins ? [task.id] : [],\n });\n }\n }\n }\n\n if (merged.size === 0) return EMPTY_ENTITLEMENTS;\n\n if (trackOrigins) {\n const entitlements: TrackedTaskEntitlement[] = [];\n for (const { entitlement, sourceTaskIds } of merged.values()) {\n entitlements.push({ ...entitlement, sourceTaskIds });\n }\n return { entitlements };\n }\n\n return { entitlements: Array.from(merged.values()).map((e) => e.entitlement) };\n}\n",
|
|
9
9
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport type { ServiceRegistry } from \"@workglow/util\";\nimport { getInputResolvers } from \"@workglow/util\";\n\n/**\n * Configuration for the input resolver\n */\nexport interface InputResolverConfig {\n readonly registry: ServiceRegistry;\n}\n\n/**\n * Extracts the format string from a schema, handling oneOf/anyOf wrappers.\n */\nexport function getSchemaFormat(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): string | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct format\n if (typeof s.format === \"string\") return s.format;\n\n // Check oneOf/anyOf/allOf for format\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (typeof v.format === \"string\") return v.format;\n }\n }\n }\n\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const fmt = getSchemaFormat(sub, visited);\n if (fmt !== undefined) return fmt;\n }\n }\n\n return undefined;\n}\n\n/**\n * Extracts the object-typed schema from a property schema, handling oneOf/anyOf wrappers.\n * This is needed for patterns like `oneOf: [{ type: \"string\" }, { type: \"object\", properties: {...} }]`\n * where the model can be either a string ID or an inline config object.\n */\nexport function getObjectSchema(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): (Record<string, unknown> & { properties: Record<string, unknown> }) | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct object schema with properties\n if (s.type === \"object\" && s.properties && typeof s.properties === \"object\") {\n return s as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n\n // Check oneOf/anyOf for object variant\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (v.type === \"object\" && v.properties && typeof v.properties === \"object\") {\n return v as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n }\n }\n }\n\n // Check allOf for object variant\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const result = getObjectSchema(sub, visited);\n if (result !== undefined) return result;\n }\n }\n\n return undefined;\n}\n\n/**\n * Gets the format prefix from a format string.\n * For \"model:TextEmbedding\" returns \"model\"\n * For \"storage:tabular\" returns \"storage\"\n */\nexport function getFormatPrefix(format: string): string {\n const colonIndex = format.indexOf(\":\");\n return colonIndex >= 0 ? format.substring(0, colonIndex) : format;\n}\n\n/**\n * Returns true if the schema has any properties with format annotations\n * (direct or in oneOf/anyOf variants). Used as a fast-path check to skip\n * resolution when no format-annotated properties exist.\n */\nexport function schemaHasFormatAnnotations(schema: DataPortSchema): boolean {\n if (typeof schema === \"boolean\") return false;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return false;\n\n for (const propSchema of Object.values(properties)) {\n if (getSchemaFormat(propSchema) !== undefined) return true;\n }\n return false;\n}\n\n/**\n * Resolves schema-annotated inputs by looking up string IDs from registries.\n * String values with matching format annotations are resolved to their instances.\n * Non-string values (objects/instances) are passed through unchanged.\n *\n * @param input The task input object\n * @param schema The task's input schema\n * @param config Configuration including the service registry\n * @returns The input with resolved values\n *\n * @example\n * ```typescript\n * // In TaskRunner.run()\n * const resolvedInput = await resolveSchemaInputs(\n * this.task.runInputData,\n * (this.task.constructor as typeof Task).inputSchema(),\n * { registry: this.registry }\n * );\n * ```\n */\nexport async function resolveSchemaInputs<T extends Record<string, unknown>>(\n input: T,\n schema: DataPortSchema,\n config: InputResolverConfig,\n visited: Set<object> = new Set()\n): Promise<T> {\n if (typeof schema === \"boolean\") return input;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return input;\n\n const resolvers = getInputResolvers();\n const resolved: Record<string, unknown> = { ...input };\n\n for (const [key, propSchema] of Object.entries(properties)) {\n let value = resolved[key];\n\n // Phase 1: Resolve format-annotated string values\n const format = getSchemaFormat(propSchema);\n if (format) {\n let resolver = resolvers.get(format);\n if (!resolver) {\n const prefix = getFormatPrefix(format);\n resolver = resolvers.get(prefix);\n }\n\n if (resolver) {\n // Handle string values\n if (typeof value === \"string\") {\n value = await resolver(value, format, config.registry);\n resolved[key] = value;\n }\n // Handle arrays - resolve string elements and pass through non-string elements unchanged\n else if (Array.isArray(value) && value.some((item) => typeof item === \"string\")) {\n const results = await Promise.all(\n value.map((item) =>\n typeof item === \"string\" ? resolver(item, format, config.registry) : item\n )\n );\n value = results.filter((result) => result !== undefined);\n resolved[key] = value;\n }\n }\n }\n\n // Phase 2: Recurse into object values if the schema defines nested properties\n if (\n value !== null &&\n value !== undefined &&\n typeof value === \"object\" &&\n !Array.isArray(value)\n ) {\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && !visited.has(objectSchema)) {\n visited.add(objectSchema);\n try {\n resolved[key] = await resolveSchemaInputs(\n value as Record<string, unknown>,\n objectSchema as DataPortSchema,\n config,\n visited\n );\n } finally {\n visited.delete(objectSchema);\n }\n }\n }\n }\n\n return resolved as T;\n}\n",
|
|
10
|
-
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport
|
|
10
|
+
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getObjectSchema, getSchemaFormat } from \"../task/InputResolver\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\n\n/**\n * Result of scanning a task graph for credential format annotations.\n */\nexport interface GraphFormatScanResult {\n /** Whether any task in the graph has a `format: \"credential\"` property in its input or config schema. */\n readonly needsCredentials: boolean;\n /** The set of format strings found (e.g., `\"credential\"`). */\n readonly credentialFormats: ReadonlySet<string>;\n}\n\n/**\n * Recursively walks a JSON Schema's properties looking for any property whose\n * format annotation matches `targetFormat`. Handles nested objects and\n * `oneOf`/`anyOf` wrappers.\n */\nfunction schemaHasFormat(schema: unknown, targetFormat: string): boolean {\n if (typeof schema !== \"object\" || schema === null) return false;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (properties && typeof properties === \"object\") {\n for (const propSchema of Object.values(properties)) {\n const format = getSchemaFormat(propSchema);\n if (format === targetFormat) return true;\n\n // Recurse into nested object schemas\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && schemaHasFormat(objectSchema, targetFormat)) return true;\n }\n }\n\n return false;\n}\n\n/**\n * Scans a task graph for any task whose input or config schema contains a\n * property with the given format annotation.\n *\n * @param graph The task graph to scan\n * @param targetFormat The format string to search for (e.g., `\"credential\"`)\n * @returns `true` if at least one task has a matching format annotation\n */\nexport function scanGraphForFormat(graph: ITaskGraph, targetFormat: string): boolean {\n for (const task of graph.getTasks()) {\n const inputSchema = task.inputSchema();\n if (typeof inputSchema !== \"boolean\" && schemaHasFormat(inputSchema, targetFormat)) {\n return true;\n }\n\n const configSchema = task.configSchema();\n if (typeof configSchema !== \"boolean\" && schemaHasFormat(configSchema, targetFormat)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Scans a task graph for credential requirements.\n *\n * A task only counts as needing credentials when it has a schema property\n * annotated with `format: \"credential\"` **and** the corresponding value is\n * actually set on the task's config or input defaults (non-empty string).\n * Annotating a schema is not enough — plenty of model configs have\n * `provider_config.credential_key` available but unused (e.g. local ONNX\n * models).\n *\n * @example\n * ```ts\n * const result = scanGraphForCredentials(graph);\n * if (result.needsCredentials) {\n * await ensureCredentialStoreUnlocked();\n * }\n * ```\n */\nexport function scanGraphForCredentials(graph: ITaskGraph): GraphFormatScanResult {\n const credentialFormats = new Set<string>();\n\n for (const task of graph.getTasks()) {\n collectUsedCredentialFormats(task.inputSchema(), task.defaults ?? {}, credentialFormats);\n collectUsedCredentialFormats(\n task.configSchema(),\n (task as unknown as { config?: Record<string, unknown> }).config ?? {},\n credentialFormats\n );\n }\n\n return {\n needsCredentials: credentialFormats.size > 0,\n credentialFormats,\n };\n}\n\n/**\n * Walk schema and data in parallel. When a property is annotated with a\n * credential format AND the corresponding data value is a non-empty string,\n * record the format. Recurses into nested object schemas.\n */\nfunction collectUsedCredentialFormats(schema: unknown, data: unknown, formats: Set<string>): void {\n if (typeof schema === \"boolean\" || typeof schema !== \"object\" || schema === null) return;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (!properties || typeof properties !== \"object\") return;\n\n const dataObj =\n typeof data === \"object\" && data !== null ? (data as Record<string, unknown>) : {};\n\n for (const [propName, propSchema] of Object.entries(properties)) {\n const format = getSchemaFormat(propSchema);\n const value = dataObj[propName];\n if (format === \"credential\" && typeof value === \"string\" && value.length > 0) {\n formats.add(format);\n }\n\n // Recurse into nested object schemas with the matching nested data\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema) {\n collectUsedCredentialFormats(objectSchema, value, formats);\n }\n }\n}\n",
|
|
11
11
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport { uuid4 } from \"@workglow/util\";\nimport { DATAFLOW_ALL_PORTS } from \"./Dataflow\";\nimport type { TaskGraph } from \"./TaskGraph\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport type {\n DataflowJson,\n JsonTaskItem,\n TaskGraphItemJson,\n TaskGraphJson,\n} from \"../task/TaskJSON\";\n\nexport interface GraphSchemaOptions {\n /**\n * When true, annotate each property with `x-source-task-id` or `x-source-task-ids`\n * to identify which task(s) the property originates from.\n */\n readonly trackOrigins?: boolean;\n}\n\n/**\n * Calculates the depth (longest path from any starting node) for each task in the graph.\n * @returns A map of task IDs to their depths\n */\nexport function calculateNodeDepths(graph: TaskGraph): Map<TaskIdType, number> {\n const depths = new Map<TaskIdType, number>();\n const tasks = graph.getTasks();\n\n for (const task of tasks) {\n depths.set(task.id, 0);\n }\n\n const sortedTasks = graph.topologicallySortedNodes();\n\n for (const task of sortedTasks) {\n const currentDepth = depths.get(task.id) || 0;\n const targetTasks = graph.getTargetTasks(task.id);\n\n for (const targetTask of targetTasks) {\n const targetDepth = depths.get(targetTask.id) || 0;\n depths.set(targetTask.id, Math.max(targetDepth, currentDepth + 1));\n }\n }\n\n return depths;\n}\n\n/**\n * Computes the input schema for a graph by examining root tasks (no incoming edges)\n * and non-root tasks with unsatisfied required inputs.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphInputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n const tasks = graph.getTasks();\n const startingNodes = tasks.filter((task) => graph.getSourceDataflows(task.id).length === 0);\n\n // Collect all properties from root tasks\n for (const task of startingNodes) {\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") {\n if (taskInputSchema === false) {\n continue;\n }\n if (taskInputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskInputSchema.properties || {};\n\n for (const [inputName, inputProp] of Object.entries(taskProperties)) {\n if (!properties[inputName]) {\n properties[inputName] = inputProp;\n\n if (taskInputSchema.required && taskInputSchema.required.includes(inputName)) {\n required.push(inputName);\n }\n\n if (trackOrigins) {\n propertyOrigins[inputName] = [task.id];\n }\n } else if (trackOrigins) {\n propertyOrigins[inputName].push(task.id);\n }\n }\n }\n\n // For non-root tasks, collect only REQUIRED properties not satisfied by dataflows.\n const sourceIds = new Set(startingNodes.map((t) => t.id));\n for (const task of tasks) {\n if (sourceIds.has(task.id)) continue;\n\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") continue;\n\n const requiredKeys = new Set<string>((taskInputSchema.required as string[] | undefined) || []);\n if (requiredKeys.size === 0) continue;\n\n const connectedPorts = new Set(\n graph.getSourceDataflows(task.id).map((df) => df.targetTaskPortId)\n );\n\n for (const key of requiredKeys) {\n if (connectedPorts.has(key)) continue;\n if (properties[key]) {\n // Property already collected — track additional origin\n if (trackOrigins) {\n propertyOrigins[key].push(task.id);\n }\n continue;\n }\n\n // Skip if the task already has a default value for this property\n if (task.defaults && task.defaults[key] !== undefined) continue;\n\n const prop = (taskInputSchema.properties || {})[key];\n if (!prop || typeof prop === \"boolean\") continue;\n\n properties[key] = prop;\n if (!required.includes(key)) {\n required.push(key);\n }\n\n if (trackOrigins) {\n propertyOrigins[key] = [task.id];\n }\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as const satisfies DataPortSchema;\n}\n\n/**\n * Computes the output schema for a graph by examining leaf tasks (no outgoing edges)\n * at the maximum depth level.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphOutputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n // Find all ending nodes (nodes with no outgoing dataflows)\n const tasks = graph.getTasks();\n const endingNodes = tasks.filter((task) => graph.getTargetDataflows(task.id).length === 0);\n\n // Calculate depths for all nodes\n const depths = calculateNodeDepths(graph);\n\n // Find the maximum depth among ending nodes\n const maxDepth = Math.max(...endingNodes.map((task) => depths.get(task.id) || 0));\n\n // Filter ending nodes to only those at the maximum depth (last level)\n const lastLevelNodes = endingNodes.filter((task) => depths.get(task.id) === maxDepth);\n\n // Count how many ending nodes produce each property\n const propertyCount: Record<string, number> = {};\n const propertySchema: Record<string, any> = {};\n\n for (const task of lastLevelNodes) {\n const taskOutputSchema = task.outputSchema();\n if (typeof taskOutputSchema === \"boolean\") {\n if (taskOutputSchema === false) {\n continue;\n }\n if (taskOutputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskOutputSchema.properties || {};\n\n for (const [outputName, outputProp] of Object.entries(taskProperties)) {\n propertyCount[outputName] = (propertyCount[outputName] || 0) + 1;\n if (!propertySchema[outputName]) {\n propertySchema[outputName] = outputProp;\n }\n if (trackOrigins) {\n if (!propertyOrigins[outputName]) {\n propertyOrigins[outputName] = [task.id];\n } else {\n propertyOrigins[outputName].push(task.id);\n }\n }\n }\n }\n\n // Build the final schema: properties produced by multiple nodes become arrays\n for (const [outputName] of Object.entries(propertyCount)) {\n const outputProp = propertySchema[outputName];\n\n if (lastLevelNodes.length === 1) {\n properties[outputName] = outputProp;\n } else {\n properties[outputName] = {\n type: \"array\",\n items: outputProp as any,\n };\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as DataPortSchema;\n}\n\n// ========================================================================\n// Boundary Node Injection\n// ========================================================================\n\n/**\n * Strips `x-source-task-id` and `x-source-task-ids` annotations from schema properties.\n */\nfunction stripOriginAnnotations(schema: DataPortSchema): DataPortSchema {\n if (typeof schema === \"boolean\" || !schema || typeof schema !== \"object\") return schema;\n const properties = schema.properties;\n if (!properties) return schema;\n\n const strippedProperties: Record<string, any> = {};\n for (const [key, prop] of Object.entries(properties)) {\n if (!prop || typeof prop !== \"object\") {\n strippedProperties[key] = prop;\n continue;\n }\n const {\n \"x-source-task-id\": _id,\n \"x-source-task-ids\": _ids,\n ...rest\n } = prop as Record<string, any>;\n strippedProperties[key] = rest;\n }\n\n return { ...schema, properties: strippedProperties } as DataPortSchema;\n}\n\n/**\n * Extracts origin task IDs from a schema property's `x-source-task-id` or `x-source-task-ids`.\n */\nfunction getOriginTaskIds(prop: Record<string, any>): TaskIdType[] {\n if (prop[\"x-source-task-ids\"]) {\n return prop[\"x-source-task-ids\"] as TaskIdType[];\n }\n if (prop[\"x-source-task-id\"] !== undefined) {\n return [prop[\"x-source-task-id\"] as TaskIdType];\n }\n return [];\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a TaskGraphJson.\n * The boundary nodes represent the graph's external interface.\n *\n * InputTask is placed first in the tasks array, OutputTask last.\n * Per-property dataflows connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToGraphJson(json: TaskGraphJson, graph: TaskGraph): TaskGraphJson {\n const hasInputTask = json.tasks.some((t) => t.type === \"InputTask\");\n const hasOutputTask = json.tasks.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return json;\n }\n\n const inputSchema = !hasInputTask\n ? computeGraphInputSchema(graph, { trackOrigins: true })\n : undefined;\n const outputSchema = !hasOutputTask\n ? computeGraphOutputSchema(graph, { trackOrigins: true })\n : undefined;\n\n const prependTasks: TaskGraphItemJson[] = [];\n const appendTasks: TaskGraphItemJson[] = [];\n const inputDataflows: DataflowJson[] = [];\n const outputDataflows: DataflowJson[] = [];\n\n if (!hasInputTask && inputSchema) {\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependTasks.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Create per-property dataflows from InputTask to origin tasks\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n inputDataflows.push({\n sourceTaskId: inputTaskId,\n sourceTaskPortId: propName,\n targetTaskId: originId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n if (!hasOutputTask && outputSchema) {\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n appendTasks.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n });\n\n // Create per-property dataflows from origin tasks to OutputTask\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n outputDataflows.push({\n sourceTaskId: originId,\n sourceTaskPortId: propName,\n targetTaskId: outputTaskId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n return {\n tasks: [...prependTasks, ...json.tasks, ...appendTasks],\n dataflows: [...inputDataflows, ...json.dataflows, ...outputDataflows],\n };\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a dependency JSON items array.\n * Per-property dependencies connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToDependencyJson(\n items: JsonTaskItem[],\n graph: TaskGraph\n): JsonTaskItem[] {\n const hasInputTask = items.some((t) => t.type === \"InputTask\");\n const hasOutputTask = items.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return items;\n }\n\n const prependItems: JsonTaskItem[] = [];\n const appendItems: JsonTaskItem[] = [];\n\n if (!hasInputTask) {\n const inputSchema = computeGraphInputSchema(graph, { trackOrigins: true });\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependItems.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Build dependencies for items that receive data from InputTask\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n const targetItem = items.find((item) => item.id === originId);\n if (!targetItem) continue;\n if (!targetItem.dependencies) {\n targetItem.dependencies = {};\n }\n const existing = targetItem.dependencies[propName];\n const dep = { id: inputTaskId, output: propName };\n if (!existing) {\n targetItem.dependencies[propName] = dep;\n } else if (Array.isArray(existing)) {\n existing.push(dep);\n } else {\n targetItem.dependencies[propName] = [existing, dep];\n }\n }\n }\n }\n }\n\n if (!hasOutputTask) {\n const outputSchema = computeGraphOutputSchema(graph, { trackOrigins: true });\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n // Build dependencies for OutputTask from origin tasks\n const outputDependencies: JsonTaskItem[\"dependencies\"] = {};\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n if (origins.length === 1) {\n outputDependencies[propName] = { id: origins[0], output: propName };\n } else if (origins.length > 1) {\n outputDependencies[propName] = origins.map((id) => ({ id, output: propName }));\n }\n }\n }\n\n appendItems.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n ...(Object.keys(outputDependencies).length > 0 ? { dependencies: outputDependencies } : {}),\n });\n }\n\n return [...prependItems, ...items, ...appendItems];\n}\n",
|
|
12
12
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { EventEmitter, ServiceRegistry, uuid4 } from \"@workglow/util\";\nimport type { ResourceScope } from \"@workglow/util\";\nimport { DirectedAcyclicGraph } from \"@workglow/util/graph\";\nimport { TaskOutputRepository } from \"../storage/TaskOutputRepository\";\nimport type { ITask } from \"../task/ITask\";\nimport type { StreamEvent } from \"../task/StreamTypes\";\nimport type { TaskEntitlements } from \"../task/TaskEntitlements\";\nimport type { JsonTaskItem, TaskGraphJson, TaskGraphJsonOptions } from \"../task/TaskJSON\";\nimport type { TaskIdType, TaskInput, TaskOutput, TaskStatus } from \"../task/TaskTypes\";\nimport type { PipeFunction } from \"./Conversions\";\nimport { ensureTask } from \"./Conversions\";\nimport type { DataflowIdType } from \"./Dataflow\";\nimport { Dataflow } from \"./Dataflow\";\nimport { computeGraphEntitlements } from \"./GraphEntitlementUtils\";\nimport { addBoundaryNodesToDependencyJson, addBoundaryNodesToGraphJson } from \"./GraphSchemaUtils\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\nimport {\n EventTaskGraphToDagMapping,\n GraphEventDagEvents,\n GraphEventDagParameters,\n TaskGraphEventListener,\n TaskGraphEvents,\n TaskGraphEventStatusParameters,\n TaskGraphStatusEvents,\n TaskGraphStatusListeners,\n} from \"./TaskGraphEvents\";\nimport type { GraphResultArray } from \"./TaskGraphRunner\";\nimport { CompoundMergeStrategy, GraphResult, TaskGraphRunner } from \"./TaskGraphRunner\";\n\n/**\n * Configuration for running a task graph\n */\nexport interface TaskGraphRunConfig {\n /** Optional output cache to use for this task graph */\n outputCache?: TaskOutputRepository | boolean;\n /** Optional signal to abort the task graph */\n parentSignal?: AbortSignal;\n /** Optional service registry to use for this task graph (creates child from global if not provided) */\n registry?: ServiceRegistry;\n /**\n * When true, streaming leaf tasks (no outgoing edges) accumulate their full\n * output so the workflow return value is complete. Defaults to true.\n * Pass false for subgraph runs where the parent handles streaming via\n * subscriptions and does not rely on the return value for stream data.\n */\n accumulateLeafOutputs?: boolean;\n /**\n * Maximum time in milliseconds for the entire graph execution.\n * When exceeded, all in-progress tasks are aborted and a TaskTimeoutError is thrown.\n */\n timeout?: number;\n /**\n * Maximum number of tasks allowed in the graph. Validated before execution starts.\n * Defaults to no limit. Set this to prevent runaway graph construction.\n */\n maxTasks?: number;\n /**\n * When true, check entitlements via the registered IEntitlementEnforcer before\n * graph execution begins. Throws TaskEntitlementError if any required (non-optional)\n * entitlements are denied. Default: false.\n */\n enforceEntitlements?: boolean;\n /**\n * Resource scope for collecting heavyweight resource disposers during graph execution.\n * Threaded to all tasks via IExecuteContext. The caller controls disposal.\n */\n resourceScope?: ResourceScope;\n}\n\nexport interface TaskGraphRunReactiveConfig extends Omit<\n TaskGraphRunConfig,\n \"enforceEntitlements\" | \"timeout\"\n> {\n /** Optional service registry to use for this task graph */\n registry?: ServiceRegistry;\n}\n\nclass TaskGraphDAG extends DirectedAcyclicGraph<\n ITask<any, any, any>,\n Dataflow,\n TaskIdType,\n DataflowIdType\n> {\n constructor() {\n super(\n (task: ITask<any, any, any>) => task.id,\n (dataflow: Dataflow) => dataflow.id\n );\n }\n}\n\ninterface TaskGraphConstructorConfig {\n outputCache?: TaskOutputRepository;\n dag?: TaskGraphDAG;\n}\n\n/**\n * Represents a task graph, a directed acyclic graph of tasks and data flows\n */\nexport class TaskGraph implements ITaskGraph {\n /** Optional output cache to use for this task graph */\n public outputCache?: TaskOutputRepository;\n\n /**\n * Constructor for TaskGraph\n * @param config Configuration for the task graph\n */\n constructor({ outputCache, dag }: TaskGraphConstructorConfig = {}) {\n this.outputCache = outputCache;\n this._dag = dag || new TaskGraphDAG();\n }\n\n private _dag: TaskGraphDAG;\n\n private _runner: TaskGraphRunner | undefined;\n public get runner(): TaskGraphRunner {\n if (!this._runner) {\n this._runner = new TaskGraphRunner(this, this.outputCache);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Public methods\n // ========================================================================\n\n /**\n * Runs the task graph\n * @param config Configuration for the graph run\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public run<ExecuteOutput extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<ExecuteOutput>> {\n return this.runner.runGraph<ExecuteOutput>(input, {\n outputCache: config?.outputCache || this.outputCache,\n parentSignal: config?.parentSignal || undefined,\n accumulateLeafOutputs: config?.accumulateLeafOutputs,\n registry: config?.registry,\n timeout: config?.timeout,\n maxTasks: config?.maxTasks,\n resourceScope: config?.resourceScope,\n });\n }\n\n /**\n * Runs the task graph reactively\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public runReactive<Output extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<Output>> {\n return this.runner.runGraphReactive<Output>(input, config);\n }\n\n /**\n * Merges the execute output to the run output\n * @param results The execute output\n * @param compoundMerge The compound merge strategy to use\n * @returns The run output\n */\n\n public mergeExecuteOutputsToRunOutput<\n ExecuteOutput extends TaskOutput,\n Merge extends CompoundMergeStrategy = CompoundMergeStrategy,\n >(\n results: GraphResultArray<ExecuteOutput>,\n compoundMerge: Merge\n ): GraphResult<ExecuteOutput, Merge> {\n return this.runner.mergeExecuteOutputsToRunOutput(results, compoundMerge);\n }\n\n /**\n * Aborts the task graph\n */\n public abort() {\n this.runner.abort();\n }\n\n /**\n * Disables the task graph\n */\n public async disable() {\n await this.runner.disable();\n }\n\n /**\n * Retrieves a task from the task graph by its id\n * @param id The id of the task to retrieve\n * @returns The task with the given id, or undefined if not found\n */\n public getTask(id: TaskIdType): ITask<any, any, any> | undefined {\n return this._dag.getNode(id);\n }\n\n /**\n * Retrieves all tasks in the task graph\n * @returns An array of tasks in the task graph\n */\n public getTasks(): ITask<any, any, any>[] {\n return this._dag.getNodes();\n }\n\n /**\n * Retrieves all tasks in the task graph topologically sorted\n * @returns An array of tasks in the task graph topologically sorted\n */\n public topologicallySortedNodes(): ITask<any, any, any>[] {\n return this._dag.topologicallySortedNodes();\n }\n\n /**\n * Adds a task to the task graph\n * @param task The task to add\n * @returns The current task graph\n */\n public addTask(fn: PipeFunction<any, any>, config?: any): unknown;\n public addTask(task: ITask<any, any, any>): unknown;\n public addTask(task: ITask<any, any, any> | PipeFunction<any, any>, config?: any): unknown {\n return this._dag.addNode(ensureTask(task, config));\n }\n\n /**\n * Adds multiple tasks to the task graph\n * @param tasks The tasks to add\n * @returns The current task graph\n */\n public addTasks(tasks: PipeFunction<any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[] | PipeFunction<any, any>[]): unknown[] {\n return this._dag.addNodes(tasks.map(ensureTask));\n }\n\n /**\n * Adds a data flow to the task graph\n * @param dataflow The data flow to add\n * @returns The current task graph\n */\n public addDataflow(dataflow: Dataflow) {\n return this._dag.addEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow);\n }\n\n /**\n * Adds multiple data flows to the task graph\n * @param dataflows The data flows to add\n * @returns The current task graph\n */\n public addDataflows(dataflows: Dataflow[]) {\n const addedEdges = dataflows.map<[s: unknown, t: unknown, e: Dataflow]>((edge) => {\n return [edge.sourceTaskId, edge.targetTaskId, edge];\n });\n return this._dag.addEdges(addedEdges);\n }\n\n /**\n * Retrieves a data flow from the task graph by its id\n * @param id The id of the data flow to retrieve\n * @returns The data flow with the given id, or undefined if not found\n */\n public getDataflow(id: DataflowIdType): Dataflow | undefined {\n for (const [, , edge] of this._dag.getEdges()) {\n if (edge.id === id) {\n return edge;\n }\n }\n return undefined;\n }\n\n /**\n * Retrieves all data flows in the task graph\n * @returns An array of data flows in the task graph\n */\n public getDataflows(): Dataflow[] {\n return this._dag.getEdges().map((edge) => edge[2]);\n }\n\n /**\n * Removes a data flow from the task graph\n * @param dataflow The data flow to remove\n * @returns The current task graph\n */\n public removeDataflow(dataflow: Dataflow) {\n return this._dag.removeEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow.id);\n }\n\n /**\n * Retrieves the data flows that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of data flows that are sources of the given task\n */\n public getSourceDataflows(taskId: unknown): Dataflow[] {\n return this._dag.inEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the data flows that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of data flows that are targets of the given task\n */\n public getTargetDataflows(taskId: unknown): Dataflow[] {\n return this._dag.outEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the tasks that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of tasks that are sources of the given task\n */\n public getSourceTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getSourceDataflows(taskId).map((dataflow) => this.getTask(dataflow.sourceTaskId)!);\n }\n\n /**\n * Retrieves the tasks that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of tasks that are targets of the given task\n */\n public getTargetTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getTargetDataflows(taskId).map((dataflow) => this.getTask(dataflow.targetTaskId)!);\n }\n\n /**\n * Removes a task from the task graph\n * @param taskId The id of the task to remove\n * @returns The current task graph\n */\n public removeTask(taskId: unknown) {\n return this._dag.removeNode(taskId);\n }\n\n public resetGraph() {\n this.runner.resetGraph(this, uuid4());\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns A TaskGraphJson object representing the tasks and dataflows\n */\n public toJSON(options?: TaskGraphJsonOptions): TaskGraphJson {\n const tasks = this.getTasks().map((node) => node.toJSON(options));\n const dataflows = this.getDataflows().map((df) => df.toJSON());\n let json: TaskGraphJson = {\n tasks,\n dataflows,\n };\n if (options?.withBoundaryNodes) {\n json = addBoundaryNodesToGraphJson(json, this);\n }\n return json;\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns An array of JsonTaskItem objects, each representing a task and its dependencies\n */\n public toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem[] {\n const tasks = this.getTasks().flatMap((node) => node.toDependencyJSON(options));\n this.getDataflows().forEach((df) => {\n const target = tasks.find((node) => node.id === df.targetTaskId)!;\n if (!target.dependencies) {\n target.dependencies = {};\n }\n const targetDeps = target.dependencies[df.targetTaskPortId];\n if (!targetDeps) {\n target.dependencies[df.targetTaskPortId] = {\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n };\n } else {\n if (Array.isArray(targetDeps)) {\n targetDeps.push({\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n });\n } else {\n target.dependencies[df.targetTaskPortId] = [\n targetDeps,\n { id: df.sourceTaskId, output: df.sourceTaskPortId },\n ];\n }\n }\n });\n if (options?.withBoundaryNodes) {\n return addBoundaryNodesToDependencyJson(tasks, this);\n }\n return tasks;\n }\n\n // ========================================================================\n // Event handling\n // ========================================================================\n\n /**\n * Event emitter for task lifecycle events\n */\n public get events(): EventEmitter<TaskGraphStatusListeners> {\n if (!this._events) {\n this._events = new EventEmitter<TaskGraphStatusListeners>();\n }\n return this._events;\n }\n protected _events: EventEmitter<TaskGraphStatusListeners> | undefined;\n\n /**\n * Subscribes to an event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n * @returns a function to unsubscribe from the event\n */\n public subscribe<Event extends TaskGraphEvents>(\n name: Event,\n fn: TaskGraphEventListener<Event>\n ): () => void {\n this.on(name, fn);\n return () => this.off(name, fn);\n }\n\n /**\n * Subscribes to status changes on all tasks (existing and future)\n * @param callback - Function called when any task's status changes\n * @param callback.taskId - The ID of the task whose status changed\n * @param callback.status - The new status of the task\n * @returns a function to unsubscribe from all task status events\n */\n public subscribeToTaskStatus(\n callback: (taskId: TaskIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to progress updates on all tasks (existing and future)\n * @param callback - Function called when any task reports progress\n * @param callback.taskId - The ID of the task reporting progress\n * @param callback.progress - The progress value (0-100)\n * @param callback.message - Optional progress message\n * @param callback.args - Additional arguments passed with the progress update\n * @returns a function to unsubscribe from all task progress events\n */\n public subscribeToTaskProgress(\n callback: (taskId: TaskIdType, progress: number, message?: string, ...args: any[]) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to progress events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to status changes on all dataflows (existing and future)\n * @param callback - Function called when any dataflow's status changes\n * @param callback.dataflowId - The ID of the dataflow whose status changed\n * @param callback.status - The new status of the dataflow\n * @returns a function to unsubscribe from all dataflow status events\n */\n public subscribeToDataflowStatus(\n callback: (dataflowId: DataflowIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing dataflows\n const dataflows = this.getDataflows();\n dataflows.forEach((dataflow) => {\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleDataflowAdded = (dataflowId: DataflowIdType) => {\n const dataflow = this.getDataflow(dataflowId);\n if (!dataflow || typeof dataflow.subscribe !== \"function\") return;\n\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"dataflow_added\", handleDataflowAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to streaming events on the task graph.\n * Listens for task_stream_start, task_stream_chunk, and task_stream_end\n * events emitted by the TaskGraphRunner during streaming task execution.\n *\n * @param callbacks - Object with optional callbacks for each streaming event\n * @returns a function to unsubscribe from all streaming events\n */\n public subscribeToTaskStreaming(callbacks: {\n onStreamStart?: (taskId: TaskIdType) => void;\n onStreamChunk?: (taskId: TaskIdType, event: StreamEvent) => void;\n onStreamEnd?: (taskId: TaskIdType, output: Record<string, any>) => void;\n }): () => void {\n const unsubscribes: (() => void)[] = [];\n\n if (callbacks.onStreamStart) {\n const unsub = this.subscribe(\"task_stream_start\", callbacks.onStreamStart);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamChunk) {\n const unsub = this.subscribe(\"task_stream_chunk\", callbacks.onStreamChunk);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamEnd) {\n const unsub = this.subscribe(\"task_stream_end\", callbacks.onStreamEnd);\n unsubscribes.push(unsub);\n }\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to entitlement changes on all tasks (existing and future).\n * When any task's entitlements change, the graph recomputes and emits its own\n * `entitlementChange` event. Structural changes (task_added, task_removed) also trigger.\n *\n * @param callback - Function called with the aggregated entitlements whenever they change\n * @returns a function to unsubscribe from all entitlement events\n */\n public subscribeToTaskEntitlements(\n callback: (entitlements: TaskEntitlements) => void\n ): () => void {\n const globalUnsubs: (() => void)[] = [];\n const taskUnsubs = new Map<TaskIdType, () => void>();\n\n const emitChange = () => {\n const entitlements = computeGraphEntitlements(this);\n this.emit(\"entitlementChange\", entitlements);\n callback(entitlements);\n };\n\n const subscribeTask = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n const unsub = task.subscribe(\"entitlementChange\", () => emitChange());\n taskUnsubs.set(taskId, unsub);\n };\n\n // Subscribe to entitlementChange events on all existing tasks\n for (const task of this.getTasks()) {\n subscribeTask(task.id);\n }\n\n // Emit the initial state immediately so subscribers don't miss the current entitlements\n emitChange();\n\n // Subscribe to new tasks being added\n globalUnsubs.push(\n this.subscribe(\"task_added\", (taskId: TaskIdType) => {\n subscribeTask(taskId);\n emitChange();\n })\n );\n\n globalUnsubs.push(\n this.subscribe(\"task_removed\", (taskId: TaskIdType) => {\n const unsub = taskUnsubs.get(taskId);\n if (unsub) {\n unsub();\n taskUnsubs.delete(taskId);\n }\n emitChange();\n })\n );\n\n return () => {\n globalUnsubs.forEach((unsub) => unsub());\n taskUnsubs.forEach((unsub) => unsub());\n taskUnsubs.clear();\n };\n }\n\n /**\n * Registers an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n on<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.on(dagEvent, fn as Parameters<typeof this._dag.on>[1]);\n }\n return this.events.on(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Removes an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n off<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.off(dagEvent, fn as Parameters<typeof this._dag.off>[1]);\n }\n return this.events.off(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n emit<E extends GraphEventDagEvents>(name: E, ...args: GraphEventDagParameters<E>): void;\n emit<E extends TaskGraphStatusEvents>(name: E, ...args: TaskGraphEventStatusParameters<E>): void;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n emit(name: string, ...args: any[]): void {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe: overload signatures guarantee correct arg types at call sites\n return (this.emit_dag as Function).call(this, name, ...args);\n } else {\n return (this.emit_local as Function).call(this, name, ...args);\n }\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_local<Event extends TaskGraphStatusEvents>(\n name: Event,\n ...args: TaskGraphEventStatusParameters<Event>\n ) {\n return this.events?.emit(name, ...args);\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_dag<Event extends GraphEventDagEvents>(\n name: Event,\n ...args: GraphEventDagParameters<Event>\n ) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n // Safe cast: GraphEventDagParameters matches the DAG's emit parameters (both are ID-based)\n return this._dag.emit(dagEvent, ...(args as unknown as [unknown]));\n }\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nfunction serialGraphEdges(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): Dataflow[] {\n const edges: Dataflow[] = [];\n for (let i = 0; i < tasks.length - 1; i++) {\n edges.push(new Dataflow(tasks[i].id, inputHandle, tasks[i + 1].id, outputHandle));\n }\n return edges;\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nexport function serialGraph(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): TaskGraph {\n const graph = new TaskGraph();\n graph.addTasks(tasks);\n graph.addDataflows(serialGraphEdges(tasks, inputHandle, outputHandle));\n return graph;\n}\n",
|
|
13
13
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getLogger } from \"@workglow/util\";\nimport type { DataPortSchema, SchemaNode } from \"@workglow/util/schema\";\nimport { compileSchema } from \"@workglow/util/schema\";\nimport { computeGraphEntitlements } from \"../task-graph/GraphEntitlementUtils\";\nimport { computeGraphInputSchema, computeGraphOutputSchema } from \"../task-graph/GraphSchemaUtils\";\nimport { TaskGraph } from \"../task-graph/TaskGraph\";\nimport { CompoundMergeStrategy, PROPERTY_ARRAY } from \"../task-graph/TaskGraphRunner\";\nimport type { CreateLoopWorkflow } from \"../task-graph/Workflow\";\nimport { GraphAsTaskRunner } from \"./GraphAsTaskRunner\";\nimport type { IExecuteContext, IRunConfig } from \"./ITask\";\nimport type { StreamEvent, StreamFinish } from \"./StreamTypes\";\nimport { Task } from \"./Task\";\nimport type { TaskEntitlements } from \"./TaskEntitlements\";\nimport type { TaskEventListener, TaskEvents } from \"./TaskEvents\";\nimport type { JsonTaskItem, TaskGraphItemJson, TaskGraphJsonOptions } from \"./TaskJSON\";\nimport type { TaskConfig, TaskInput, TaskOutput, TaskTypeName } from \"./TaskTypes\";\nimport { TaskConfigSchema } from \"./TaskTypes\";\n\nexport const graphAsTaskConfigSchema = {\n type: \"object\",\n properties: {\n ...TaskConfigSchema[\"properties\"],\n compoundMerge: { type: \"string\", \"x-ui-hidden\": true },\n },\n additionalProperties: false,\n} as const satisfies DataPortSchema;\n\nexport type GraphAsTaskConfig<Input extends TaskInput = TaskInput> = TaskConfig<Input> & {\n /** subGraph is extracted in the constructor before validation — not in the JSON schema */\n subGraph?: TaskGraph;\n compoundMerge?: CompoundMergeStrategy;\n};\n\n/**\n * A task that contains a subgraph of tasks\n */\nexport class GraphAsTask<\n Input extends TaskInput = TaskInput,\n Output extends TaskOutput = TaskOutput,\n Config extends GraphAsTaskConfig<Input> = GraphAsTaskConfig<Input>,\n> extends Task<Input, Output, Config> {\n // ========================================================================\n // Static properties - should be overridden by subclasses\n // ========================================================================\n\n public static override type: TaskTypeName = \"GraphAsTask\";\n public static override title: string = \"Group\";\n public static override description: string = \"A group of tasks that are executed together\";\n public static override category: string = \"Flow Control\";\n public static compoundMerge: CompoundMergeStrategy = PROPERTY_ARRAY;\n\n /** This task has dynamic schemas that change based on the subgraph structure */\n public static override hasDynamicSchemas: boolean = true;\n\n /** Entitlements are always dynamic — they depend on child tasks in the subgraph */\n public static override hasDynamicEntitlements: boolean = true;\n\n // ========================================================================\n // Constructor\n // ========================================================================\n\n /**\n * @param config Task configuration; `subGraph` is applied to this instance and stripped before validating config.\n * @param runConfig Runtime configuration (forwarded to {@link Task}).\n */\n constructor(config: Partial<Config> = {}, runConfig: Partial<IRunConfig> = {}) {\n const { subGraph, ...rest } = config;\n super(rest as Partial<Config>, runConfig);\n if (subGraph) {\n this.subGraph = subGraph;\n }\n this.regenerateGraph();\n }\n\n // ========================================================================\n // TaskRunner delegation - Executes and manages the task\n // ========================================================================\n\n declare _runner: GraphAsTaskRunner<Input, Output, Config>;\n\n /**\n * Task runner for handling the task execution\n */\n override get runner(): GraphAsTaskRunner<Input, Output, Config> {\n if (!this._runner) {\n this._runner = new GraphAsTaskRunner<Input, Output, Config>(this);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Static to Instance conversion methods\n // ========================================================================\n\n public static override configSchema(): DataPortSchema {\n return graphAsTaskConfigSchema;\n }\n\n public get compoundMerge(): CompoundMergeStrategy {\n return this.config?.compoundMerge || (this.constructor as typeof GraphAsTask).compoundMerge;\n }\n\n public override get cacheable(): boolean {\n return (\n this.runConfig?.cacheable ??\n this.config?.cacheable ??\n ((this.constructor as typeof GraphAsTask).cacheable && !this.hasChildren())\n );\n }\n\n // ========================================================================\n // Input/Output handling\n // ========================================================================\n\n /**\n * Override inputSchema to compute it dynamically from the subgraph at runtime.\n * For root tasks (no incoming edges) all input properties are collected.\n * For non-root tasks, only REQUIRED properties that are not satisfied by\n * any internal dataflow are added — this ensures that required inputs are\n * included in the graph's input schema without pulling in every optional\n * downstream property.\n */\n public override inputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).inputSchema();\n }\n\n return computeGraphInputSchema(this.subGraph);\n }\n\n protected _inputSchemaNode: SchemaNode | undefined;\n /**\n * Gets the compiled input schema\n */\n protected override getInputSchemaNode(): SchemaNode {\n // every graph as task is different, so we need to compile the schema for each one\n if (!this._inputSchemaNode) {\n try {\n const dataPortSchema = this.inputSchema();\n const schemaNode = Task.generateInputSchemaNode(dataPortSchema);\n this._inputSchemaNode = schemaNode;\n } catch (error) {\n // If compilation fails, fall back to accepting any object structure.\n // This is a safety net for schemas that json-schema-library can't compile.\n getLogger().warn(\n `GraphAsTask \"${this.type}\" (${this.id}): Failed to compile input schema, ` +\n `falling back to permissive validation. Inputs will NOT be validated.`,\n { error, taskType: this.type, taskId: this.id }\n );\n this._inputSchemaNode = compileSchema({});\n }\n }\n return this._inputSchemaNode!;\n }\n\n /**\n\n * Override outputSchema to compute it dynamically from the subgraph at runtime\n * The output schema depends on the compoundMerge strategy and the nodes at the last level\n */\n\n public override outputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).outputSchema();\n }\n\n return computeGraphOutputSchema(this.subGraph);\n }\n\n /**\n * Override entitlements to aggregate from all tasks in the subgraph.\n */\n public override entitlements(): TaskEntitlements {\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).entitlements();\n }\n return computeGraphEntitlements(this.subGraph);\n }\n\n /**\n * Resets input data to defaults\n */\n public override resetInputData(): void {\n super.resetInputData();\n if (this.hasChildren()) {\n this.subGraph!.getTasks().forEach((node) => {\n node.resetInputData();\n });\n this.subGraph!.getDataflows().forEach((dataflow) => {\n dataflow.reset();\n });\n }\n }\n\n // ========================================================================\n // Streaming pass-through\n // ========================================================================\n\n /**\n * Stream pass-through for compound tasks: runs the subgraph and forwards\n * streaming events from ending nodes to the outer graph. Also re-yields\n * any input streams from upstream for cases where this GraphAsTask is\n * itself downstream of another streaming task.\n */\n async *executeStream(input: Input, context: IExecuteContext): AsyncIterable<StreamEvent<Output>> {\n // Forward upstream input streams first (pass-through from outer graph)\n if (context.inputStreams) {\n for (const [, stream] of context.inputStreams) {\n const reader = stream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value.type === \"finish\") continue;\n yield value as StreamEvent<Output>;\n }\n } finally {\n reader.releaseLock();\n }\n }\n }\n\n // Run the subgraph and forward streaming events from ending nodes\n if (this.hasChildren()) {\n const endingNodeIds = new Set<unknown>();\n const tasks = this.subGraph.getTasks();\n for (const task of tasks) {\n if (this.subGraph.getTargetDataflows(task.id).length === 0) {\n endingNodeIds.add(task.id);\n }\n }\n\n const eventQueue: StreamEvent<Output>[] = [];\n let subgraphDone = false;\n\n // Eager promise/resolver — always available for producers to signal.\n // Prevents a race where producers call a stale or undefined resolver,\n // causing the generator to hang on a promise that never resolves.\n // `isWaiting` is true only while the generator is suspended at `await notifyPromise`.\n // `hasPending` records a notification that arrived while the generator was active,\n // so the generator skips the next wait without allocating a new promise.\n let { promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>();\n let isWaiting = false;\n let hasPending = false;\n const notify = () => {\n if (isWaiting) {\n // Wake the generator and prepare a fresh deferred for the next wait.\n notifyResolve();\n ({ promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>());\n isWaiting = false;\n } else {\n // Generator is still draining; skip the allocation.\n hasPending = true;\n }\n };\n\n const unsub = this.subGraph.subscribeToTaskStreaming({\n onStreamChunk: (taskId, event) => {\n if (endingNodeIds.has(taskId) && event.type !== \"finish\") {\n eventQueue.push(event as StreamEvent<Output>);\n notify();\n }\n },\n });\n\n const runPromise = this.subGraph\n .run<Output>(input, { parentSignal: context.signal, accumulateLeafOutputs: false })\n .then(\n (results) => {\n subgraphDone = true;\n notify();\n return results;\n },\n (err) => {\n subgraphDone = true;\n notify();\n throw err;\n }\n );\n\n // Yield events as they arrive from ending nodes\n while (!subgraphDone) {\n if (eventQueue.length === 0) {\n if (hasPending) {\n // A notification arrived while we were active; consume it without blocking.\n hasPending = false;\n } else {\n isWaiting = true;\n await notifyPromise;\n }\n }\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n }\n // Drain any remaining events\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n\n unsub();\n\n const results = await runPromise;\n const mergedOutput = this.subGraph.mergeExecuteOutputsToRunOutput(\n results,\n this.compoundMerge\n ) as Output;\n yield { type: \"finish\", data: mergedOutput } as StreamFinish<Output>;\n } else {\n yield { type: \"finish\", data: input as unknown as Output } as StreamFinish<Output>;\n }\n }\n\n // ========================================================================\n // Compound task methods\n // ========================================================================\n\n /**\n * Regenerates the subtask graph and emits a \"regenerate\" event\n *\n * Subclasses should override this method to implement the actual graph\n * regeneration logic, but all they need to do is call this method to\n * emit the \"regenerate\" event.\n */\n public override regenerateGraph(): void {\n this._inputSchemaNode = undefined;\n this.events.emit(\"regenerate\");\n this.emitEntitlementChange();\n }\n\n // ========================================================================\n // SubGraph entitlement forwarding\n // ========================================================================\n\n /** Unsubscribe handle for the current subGraph entitlement subscription */\n private _entitlementUnsub: (() => void) | undefined;\n\n /**\n * Guards against re-entry while the synchronous initial emit of\n * `subscribeToTaskEntitlements` is unwinding. Without this, the initial\n * emit's callback re-reads `this.subGraph`, which would re-trigger\n * `_syncSubGraphEntitlementSubscription` before `_entitlementUnsub` has\n * been assigned and loop forever.\n */\n private _subscribingEntitlements: boolean = false;\n\n // ========================================================================\n // SubGraph entitlement subscription\n // ========================================================================\n\n /**\n * Subscribe to the subGraph's aggregated entitlement changes and forward\n * them as an entitlementChange event on this task so that the parent\n * TaskGraph / Workflow sees the update.\n */\n private _subscribeToSubGraphEntitlements(graph: TaskGraph): void {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n this._subscribingEntitlements = true;\n try {\n this._entitlementUnsub = graph.subscribeToTaskEntitlements(() => {\n this.emitEntitlementChange();\n });\n } finally {\n this._subscribingEntitlements = false;\n }\n }\n\n private _syncSubGraphEntitlementSubscription(\n graph: TaskGraph | undefined = this._subGraph\n ): void {\n if (this._subscribingEntitlements) return;\n\n if ((this._events?.listenerCount(\"entitlementChange\") ?? 0) === 0) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n return;\n }\n\n if (!graph || this._entitlementUnsub) {\n return;\n }\n\n this._subscribeToSubGraphEntitlements(graph);\n }\n\n public override subscribe<Event extends TaskEvents>(\n name: Event,\n fn: TaskEventListener<Event>\n ): () => void {\n const unsub = super.subscribe(name, fn);\n if (name !== \"entitlementChange\") {\n return unsub;\n }\n\n this._syncSubGraphEntitlementSubscription();\n\n return () => {\n unsub();\n this._syncSubGraphEntitlementSubscription();\n };\n }\n\n public override on<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.on(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override off<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.off(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override once<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.once(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override set subGraph(subGraph: TaskGraph) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n super.subGraph = subGraph;\n this._syncSubGraphEntitlementSubscription(subGraph);\n }\n\n override get subGraph(): TaskGraph {\n const graph = super.subGraph;\n // The base getter may have lazily created a new graph — subscribe only when needed.\n this._syncSubGraphEntitlementSubscription(graph);\n return graph;\n }\n\n // ========================================================================\n // Serialization methods\n // ========================================================================\n\n /**\n * Serializes the task and its subtasks into a format that can be stored\n * @returns The serialized task and subtasks\n */\n public override toJSON(options?: TaskGraphJsonOptions): TaskGraphItemJson {\n let json = super.toJSON(options);\n const hasChildren = this.hasChildren();\n if (hasChildren) {\n json = {\n ...json,\n merge: this.compoundMerge,\n subgraph: this.subGraph!.toJSON(options),\n };\n }\n return json;\n }\n\n /**\n * Converts the task to a JSON format suitable for dependency tracking\n * @returns The task and subtasks in JSON thats easier for humans to read\n */\n public override toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem {\n const json = this.toJSON(options);\n if (this.hasChildren()) {\n if (\"subgraph\" in json) {\n delete json.subgraph;\n }\n return { ...json, subtasks: this.subGraph!.toDependencyJSON(options) };\n }\n return json;\n }\n}\n\ndeclare module \"../task-graph/Workflow\" {\n interface Workflow {\n /**\n * Starts a group that wraps inner tasks in a GraphAsTask subgraph.\n * Use .endGroup() to close the group and return to the parent workflow.\n */\n group: CreateLoopWorkflow<TaskInput, TaskOutput, GraphAsTaskConfig<TaskInput>>;\n\n /**\n * Ends the group and returns to the parent workflow.\n */\n endGroup(): Workflow;\n }\n}\n\n// Prototype assignments live in Workflow.ts (bottom of file) to avoid\n// circular-dependency issues at module evaluation time.\n",
|
package/dist/node.js.map
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { TaskIdType } from \"./TaskTypes\";\n\n// ========================================================================\n// Entitlement Types\n// ========================================================================\n\n/**\n * Hierarchical entitlement identifier.\n * Uses colon-separated namespacing: \"network\", \"network:http\", \"network:websocket\"\n * A grant of \"network\" implicitly covers \"network:http\" and \"network:websocket\".\n */\nexport type EntitlementId = string;\n\n/**\n * A single entitlement declaration.\n */\nexport interface TaskEntitlement {\n /** Hierarchical identifier, e.g. \"network:http\", \"credential:anthropic\", \"code-execution:javascript\" */\n readonly id: EntitlementId;\n /** Human-readable reason why this entitlement is needed */\n readonly reason?: string;\n /** Whether this entitlement is optional (task can degrade gracefully without it) */\n readonly optional?: boolean;\n /**\n * Specific resources this entitlement applies to.\n * E.g. URL patterns for network, model IDs for ai:model, server names for mcp.\n * When undefined, the entitlement applies broadly.\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Complete entitlement declaration for a task or graph.\n */\nexport interface TaskEntitlements {\n /** List of entitlements required */\n readonly entitlements: readonly TaskEntitlement[];\n}\n\n/**\n * An entitlement with origin tracking (which task(s) require it).\n */\nexport interface TrackedTaskEntitlement extends TaskEntitlement {\n /** Task IDs that require this entitlement */\n readonly sourceTaskIds: readonly TaskIdType[];\n}\n\n/**\n * Entitlements with optional origin tracking.\n */\nexport interface TrackedTaskEntitlements {\n readonly entitlements: readonly TrackedTaskEntitlement[];\n}\n\n// ========================================================================\n// Well-Known Entitlement Constants\n// ========================================================================\n\n/**\n * Well-known entitlement categories. Tasks may also use custom IDs beyond these.\n */\nexport const Entitlements = {\n // Network\n NETWORK: \"network\",\n NETWORK_HTTP: \"network:http\",\n NETWORK_WEBSOCKET: \"network:websocket\",\n NETWORK_PRIVATE: \"network:private\",\n\n // File system\n FILESYSTEM: \"filesystem\",\n FILESYSTEM_READ: \"filesystem:read\",\n FILESYSTEM_WRITE: \"filesystem:write\",\n\n // Code execution\n CODE_EXECUTION: \"code-execution\",\n CODE_EXECUTION_JS: \"code-execution:javascript\",\n\n // Credentials\n CREDENTIAL: \"credential\",\n\n // AI models\n AI: \"ai\",\n AI_MODEL: \"ai:model\",\n AI_INFERENCE: \"ai:inference\",\n\n // MCP\n MCP: \"mcp\",\n MCP_TOOL_CALL: \"mcp:tool-call\",\n MCP_RESOURCE_READ: \"mcp:resource-read\",\n MCP_PROMPT_GET: \"mcp:prompt-get\",\n MCP_STDIO: \"mcp:stdio\",\n\n // Storage / database\n STORAGE: \"storage\",\n STORAGE_READ: \"storage:read\",\n STORAGE_WRITE: \"storage:write\",\n\n // Browser automation\n BROWSER_CONTROL: \"browser\",\n BROWSER_CONTROL_LOCAL: \"browser:local\",\n BROWSER_CONTROL_CLOUD: \"browser:cloud\",\n BROWSER_CONTROL_NAVIGATE: \"browser:navigate\",\n BROWSER_CONTROL_EVALUATE: \"browser:evaluate\",\n BROWSER_CONTROL_CREDENTIAL: \"browser:credential\",\n} as const;\n\n// ========================================================================\n// Empty Entitlements Singleton\n// ========================================================================\n\n/** Shared empty entitlements object to avoid unnecessary allocations */\nexport const EMPTY_ENTITLEMENTS: TaskEntitlements = Object.freeze({\n entitlements: Object.freeze([]),\n});\n\n// ========================================================================\n// Utility Functions\n// ========================================================================\n\n/**\n * Check if a granted entitlement covers a required entitlement.\n * \"network\" covers \"network:http\" (parent covers child in hierarchy).\n */\nexport function entitlementCovers(granted: EntitlementId, required: EntitlementId): boolean {\n return required === granted || required.startsWith(granted + \":\");\n}\n\n/**\n * A grant declaration — what a consumer is willing to allow.\n * Unlike TaskEntitlement (which declares what a task *needs*), this declares what is *permitted*.\n */\nexport interface EntitlementGrant {\n /** Entitlement ID to grant. Hierarchy applies: granting \"network\" covers \"network:http\". */\n readonly id: EntitlementId;\n /**\n * Specific resources this grant covers.\n * - undefined → broad grant, covers all resources for this entitlement\n * - string[] → scoped grant, only covers requirements whose resources are a subset\n *\n * Supports glob-style patterns with any number of `*` wildcards.\n * Each `*` matches zero or more characters of any kind, including `/`.\n * - \"/tmp/*\" covers \"/tmp/data.json\" and \"/tmp/subdir/file.txt\"\n * - \"*.example.com\" covers \"api.example.com\"\n * - \"file-*-v*.json\" covers \"file-data-v2.json\"\n */\n readonly resources?: readonly string[];\n}\n\n/**\n * Check if a single grant resource pattern matches a single required resource.\n * Supports glob-style patterns with any number of `*` wildcards; each `*`\n * matches zero or more characters of any kind (including `/`).\n * - \"prefix*\" matches anything starting with \"prefix\"\n * - \"*.example.com\" matches anything ending with \".example.com\"\n * - \"pre*suf\" matches anything with the given prefix and suffix\n * - \"a*b*c\" matches anything containing \"a\", then \"b\", then \"c\" in order\n * Without `*`, requires exact match.\n */\nexport function resourcePatternMatches(grantPattern: string, requiredResource: string): boolean {\n if (grantPattern === requiredResource) return true;\n if (!grantPattern.includes(\"*\")) return false;\n\n const parts = grantPattern.split(\"*\");\n const first = parts[0];\n const last = parts[parts.length - 1];\n\n if (!requiredResource.startsWith(first)) return false;\n if (!requiredResource.endsWith(last)) return false;\n\n let fixedLength = 0;\n for (const p of parts) fixedLength += p.length;\n if (requiredResource.length < fixedLength) return false;\n\n let searchStart = first.length;\n const searchEnd = requiredResource.length - last.length;\n for (let i = 1; i < parts.length - 1; i++) {\n const part = parts[i];\n if (part.length === 0) continue; // consecutive wildcards collapse\n const idx = requiredResource.indexOf(part, searchStart);\n if (idx === -1 || idx + part.length > searchEnd) return false;\n searchStart = idx + part.length;\n }\n\n return true;\n}\n\n/**\n * Check if a grant covers the resource requirements of an entitlement.\n *\n * Matching rules:\n * - Grant has no resources (broad) → covers any resource requirement\n * - Requirement has no resources (broad need) → only a broad grant covers it\n * - Both have resources → every required resource must match at least one grant pattern\n */\nexport function grantCoversResources(grant: EntitlementGrant, required: TaskEntitlement): boolean {\n // Broad grant covers everything\n if (grant.resources === undefined) return true;\n // Scoped grant cannot cover a broad requirement\n if (required.resources === undefined) return false;\n // Every required resource must be covered by at least one grant pattern\n return required.resources.every((req) =>\n grant.resources!.some((pat) => resourcePatternMatches(pat, req))\n );\n}\n\n/**\n * Merge two TaskEntitlements into a union (deduplicating by ID).\n * If the same ID appears in both, optional is false if either is false (most restrictive wins).\n * Resources are merged (union of all resource arrays for the same ID).\n */\nexport function mergeEntitlements(a: TaskEntitlements, b: TaskEntitlements): TaskEntitlements {\n if (a.entitlements.length === 0) return b;\n if (b.entitlements.length === 0) return a;\n\n const merged = new Map<EntitlementId, TaskEntitlement>();\n\n for (const entitlement of a.entitlements) {\n merged.set(entitlement.id, entitlement);\n }\n\n for (const entitlement of b.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n merged.set(entitlement.id, mergeEntitlementPair(existing, entitlement));\n } else {\n merged.set(entitlement.id, entitlement);\n }\n }\n\n return { entitlements: Array.from(merged.values()) };\n}\n\n/**\n * Merge two entitlements with the same ID.\n * - optional: false wins (most restrictive)\n * - reason: first non-empty wins\n * - resources: union\n */\nexport function mergeEntitlementPair(a: TaskEntitlement, b: TaskEntitlement): TaskEntitlement {\n const optional = (a.optional ?? false) && (b.optional ?? false) ? true : undefined;\n const reason = a.reason ?? b.reason;\n const resources = mergeResources(a.resources, b.resources);\n\n const result: TaskEntitlement = {\n id: a.id,\n ...(reason !== undefined && { reason }),\n ...(optional === true && { optional: true }),\n ...(resources !== undefined && { resources }),\n };\n return result;\n}\n\nexport function mergeResources(\n a: readonly string[] | undefined,\n b: readonly string[] | undefined\n): readonly string[] | undefined {\n // undefined means \"all resources\" (broad), so if either side is broad the merged result stays broad\n if (a === undefined || b === undefined) return undefined;\n const set = new Set([...a, ...b]);\n return Array.from(set);\n}\n",
|
|
8
8
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n type EntitlementId,\n type TaskEntitlement,\n type TaskEntitlements,\n type TrackedTaskEntitlement,\n type TrackedTaskEntitlements,\n EMPTY_ENTITLEMENTS,\n mergeEntitlementPair,\n} from \"../task/TaskEntitlements\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport { TaskStatus } from \"../task/TaskTypes\";\nimport type { TaskGraph } from \"./TaskGraph\";\n\n// ========================================================================\n// Options\n// ========================================================================\n\nexport interface GraphEntitlementOptions {\n /**\n * When true, annotate each entitlement with the source task IDs that require it.\n */\n readonly trackOrigins?: boolean;\n /**\n * Controls which ConditionalTask branches are included.\n * - \"all\" (default): Include entitlements from ALL branches (conservative, pre-execution analysis)\n * - \"active\": Only include entitlements from currently active branches (runtime, after conditions evaluated)\n */\n readonly conditionalBranches?: \"all\" | \"active\";\n}\n\n// ========================================================================\n// Graph Entitlement Computation\n// ========================================================================\n\n/**\n * Computes the aggregated entitlements for a TaskGraph.\n * Returns the union of all task entitlements in the graph.\n *\n * When `trackOrigins` is true, returns TrackedTaskEntitlements with source task IDs.\n */\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions & { readonly trackOrigins: true }\n): TrackedTaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements;\nexport function computeGraphEntitlements(\n graph: TaskGraph,\n options?: GraphEntitlementOptions\n): TaskEntitlements | TrackedTaskEntitlements {\n const tasks = graph.getTasks();\n if (tasks.length === 0) return EMPTY_ENTITLEMENTS;\n\n const trackOrigins = options?.trackOrigins ?? false;\n const conditionalBranches = options?.conditionalBranches ?? \"all\";\n\n // Accumulate entitlements by ID\n const merged = new Map<\n EntitlementId,\n { entitlement: TaskEntitlement; sourceTaskIds: TaskIdType[] }\n >();\n\n for (const task of tasks) {\n // For ConditionalTask with \"active\" mode, skip disabled tasks\n if (conditionalBranches === \"active\" && task.status !== undefined) {\n if (task.status === TaskStatus.DISABLED) continue;\n }\n\n const taskEntitlements = task.entitlements();\n for (const entitlement of taskEntitlements.entitlements) {\n const existing = merged.get(entitlement.id);\n if (existing) {\n // Merge: optional=false wins, resources are unioned\n existing.entitlement = mergeEntitlementPair(existing.entitlement, entitlement);\n if (trackOrigins) {\n existing.sourceTaskIds.push(task.id);\n }\n } else {\n merged.set(entitlement.id, {\n entitlement,\n sourceTaskIds: trackOrigins ? [task.id] : [],\n });\n }\n }\n }\n\n if (merged.size === 0) return EMPTY_ENTITLEMENTS;\n\n if (trackOrigins) {\n const entitlements: TrackedTaskEntitlement[] = [];\n for (const { entitlement, sourceTaskIds } of merged.values()) {\n entitlements.push({ ...entitlement, sourceTaskIds });\n }\n return { entitlements };\n }\n\n return { entitlements: Array.from(merged.values()).map((e) => e.entitlement) };\n}\n",
|
|
9
9
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport type { ServiceRegistry } from \"@workglow/util\";\nimport { getInputResolvers } from \"@workglow/util\";\n\n/**\n * Configuration for the input resolver\n */\nexport interface InputResolverConfig {\n readonly registry: ServiceRegistry;\n}\n\n/**\n * Extracts the format string from a schema, handling oneOf/anyOf wrappers.\n */\nexport function getSchemaFormat(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): string | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct format\n if (typeof s.format === \"string\") return s.format;\n\n // Check oneOf/anyOf/allOf for format\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (typeof v.format === \"string\") return v.format;\n }\n }\n }\n\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const fmt = getSchemaFormat(sub, visited);\n if (fmt !== undefined) return fmt;\n }\n }\n\n return undefined;\n}\n\n/**\n * Extracts the object-typed schema from a property schema, handling oneOf/anyOf wrappers.\n * This is needed for patterns like `oneOf: [{ type: \"string\" }, { type: \"object\", properties: {...} }]`\n * where the model can be either a string ID or an inline config object.\n */\nexport function getObjectSchema(\n schema: unknown,\n visited: WeakSet<object> = new WeakSet()\n): (Record<string, unknown> & { properties: Record<string, unknown> }) | undefined {\n if (typeof schema !== \"object\" || schema === null) return undefined;\n if (visited.has(schema)) return undefined;\n visited.add(schema);\n\n const s = schema as Record<string, unknown>;\n\n // Direct object schema with properties\n if (s.type === \"object\" && s.properties && typeof s.properties === \"object\") {\n return s as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n\n // Check oneOf/anyOf for object variant\n const variants = (s.oneOf ?? s.anyOf) as unknown[] | undefined;\n if (Array.isArray(variants)) {\n for (const variant of variants) {\n if (typeof variant === \"object\" && variant !== null) {\n const v = variant as Record<string, unknown>;\n if (v.type === \"object\" && v.properties && typeof v.properties === \"object\") {\n return v as Record<string, unknown> & { properties: Record<string, unknown> };\n }\n }\n }\n }\n\n // Check allOf for object variant\n const allOf = s.allOf as unknown[] | undefined;\n if (Array.isArray(allOf)) {\n for (const sub of allOf) {\n const result = getObjectSchema(sub, visited);\n if (result !== undefined) return result;\n }\n }\n\n return undefined;\n}\n\n/**\n * Gets the format prefix from a format string.\n * For \"model:TextEmbedding\" returns \"model\"\n * For \"storage:tabular\" returns \"storage\"\n */\nexport function getFormatPrefix(format: string): string {\n const colonIndex = format.indexOf(\":\");\n return colonIndex >= 0 ? format.substring(0, colonIndex) : format;\n}\n\n/**\n * Returns true if the schema has any properties with format annotations\n * (direct or in oneOf/anyOf variants). Used as a fast-path check to skip\n * resolution when no format-annotated properties exist.\n */\nexport function schemaHasFormatAnnotations(schema: DataPortSchema): boolean {\n if (typeof schema === \"boolean\") return false;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return false;\n\n for (const propSchema of Object.values(properties)) {\n if (getSchemaFormat(propSchema) !== undefined) return true;\n }\n return false;\n}\n\n/**\n * Resolves schema-annotated inputs by looking up string IDs from registries.\n * String values with matching format annotations are resolved to their instances.\n * Non-string values (objects/instances) are passed through unchanged.\n *\n * @param input The task input object\n * @param schema The task's input schema\n * @param config Configuration including the service registry\n * @returns The input with resolved values\n *\n * @example\n * ```typescript\n * // In TaskRunner.run()\n * const resolvedInput = await resolveSchemaInputs(\n * this.task.runInputData,\n * (this.task.constructor as typeof Task).inputSchema(),\n * { registry: this.registry }\n * );\n * ```\n */\nexport async function resolveSchemaInputs<T extends Record<string, unknown>>(\n input: T,\n schema: DataPortSchema,\n config: InputResolverConfig,\n visited: Set<object> = new Set()\n): Promise<T> {\n if (typeof schema === \"boolean\") return input;\n\n const properties = schema.properties;\n if (!properties || typeof properties !== \"object\") return input;\n\n const resolvers = getInputResolvers();\n const resolved: Record<string, unknown> = { ...input };\n\n for (const [key, propSchema] of Object.entries(properties)) {\n let value = resolved[key];\n\n // Phase 1: Resolve format-annotated string values\n const format = getSchemaFormat(propSchema);\n if (format) {\n let resolver = resolvers.get(format);\n if (!resolver) {\n const prefix = getFormatPrefix(format);\n resolver = resolvers.get(prefix);\n }\n\n if (resolver) {\n // Handle string values\n if (typeof value === \"string\") {\n value = await resolver(value, format, config.registry);\n resolved[key] = value;\n }\n // Handle arrays - resolve string elements and pass through non-string elements unchanged\n else if (Array.isArray(value) && value.some((item) => typeof item === \"string\")) {\n const results = await Promise.all(\n value.map((item) =>\n typeof item === \"string\" ? resolver(item, format, config.registry) : item\n )\n );\n value = results.filter((result) => result !== undefined);\n resolved[key] = value;\n }\n }\n }\n\n // Phase 2: Recurse into object values if the schema defines nested properties\n if (\n value !== null &&\n value !== undefined &&\n typeof value === \"object\" &&\n !Array.isArray(value)\n ) {\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && !visited.has(objectSchema)) {\n visited.add(objectSchema);\n try {\n resolved[key] = await resolveSchemaInputs(\n value as Record<string, unknown>,\n objectSchema as DataPortSchema,\n config,\n visited\n );\n } finally {\n visited.delete(objectSchema);\n }\n }\n }\n }\n\n return resolved as T;\n}\n",
|
|
10
|
-
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport
|
|
10
|
+
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getObjectSchema, getSchemaFormat } from \"../task/InputResolver\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\n\n/**\n * Result of scanning a task graph for credential format annotations.\n */\nexport interface GraphFormatScanResult {\n /** Whether any task in the graph has a `format: \"credential\"` property in its input or config schema. */\n readonly needsCredentials: boolean;\n /** The set of format strings found (e.g., `\"credential\"`). */\n readonly credentialFormats: ReadonlySet<string>;\n}\n\n/**\n * Recursively walks a JSON Schema's properties looking for any property whose\n * format annotation matches `targetFormat`. Handles nested objects and\n * `oneOf`/`anyOf` wrappers.\n */\nfunction schemaHasFormat(schema: unknown, targetFormat: string): boolean {\n if (typeof schema !== \"object\" || schema === null) return false;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (properties && typeof properties === \"object\") {\n for (const propSchema of Object.values(properties)) {\n const format = getSchemaFormat(propSchema);\n if (format === targetFormat) return true;\n\n // Recurse into nested object schemas\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema && schemaHasFormat(objectSchema, targetFormat)) return true;\n }\n }\n\n return false;\n}\n\n/**\n * Scans a task graph for any task whose input or config schema contains a\n * property with the given format annotation.\n *\n * @param graph The task graph to scan\n * @param targetFormat The format string to search for (e.g., `\"credential\"`)\n * @returns `true` if at least one task has a matching format annotation\n */\nexport function scanGraphForFormat(graph: ITaskGraph, targetFormat: string): boolean {\n for (const task of graph.getTasks()) {\n const inputSchema = task.inputSchema();\n if (typeof inputSchema !== \"boolean\" && schemaHasFormat(inputSchema, targetFormat)) {\n return true;\n }\n\n const configSchema = task.configSchema();\n if (typeof configSchema !== \"boolean\" && schemaHasFormat(configSchema, targetFormat)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Scans a task graph for credential requirements.\n *\n * A task only counts as needing credentials when it has a schema property\n * annotated with `format: \"credential\"` **and** the corresponding value is\n * actually set on the task's config or input defaults (non-empty string).\n * Annotating a schema is not enough — plenty of model configs have\n * `provider_config.credential_key` available but unused (e.g. local ONNX\n * models).\n *\n * @example\n * ```ts\n * const result = scanGraphForCredentials(graph);\n * if (result.needsCredentials) {\n * await ensureCredentialStoreUnlocked();\n * }\n * ```\n */\nexport function scanGraphForCredentials(graph: ITaskGraph): GraphFormatScanResult {\n const credentialFormats = new Set<string>();\n\n for (const task of graph.getTasks()) {\n collectUsedCredentialFormats(task.inputSchema(), task.defaults ?? {}, credentialFormats);\n collectUsedCredentialFormats(\n task.configSchema(),\n (task as unknown as { config?: Record<string, unknown> }).config ?? {},\n credentialFormats\n );\n }\n\n return {\n needsCredentials: credentialFormats.size > 0,\n credentialFormats,\n };\n}\n\n/**\n * Walk schema and data in parallel. When a property is annotated with a\n * credential format AND the corresponding data value is a non-empty string,\n * record the format. Recurses into nested object schemas.\n */\nfunction collectUsedCredentialFormats(schema: unknown, data: unknown, formats: Set<string>): void {\n if (typeof schema === \"boolean\" || typeof schema !== \"object\" || schema === null) return;\n const s = schema as Record<string, unknown>;\n\n const properties = s.properties as Record<string, unknown> | undefined;\n if (!properties || typeof properties !== \"object\") return;\n\n const dataObj =\n typeof data === \"object\" && data !== null ? (data as Record<string, unknown>) : {};\n\n for (const [propName, propSchema] of Object.entries(properties)) {\n const format = getSchemaFormat(propSchema);\n const value = dataObj[propName];\n if (format === \"credential\" && typeof value === \"string\" && value.length > 0) {\n formats.add(format);\n }\n\n // Recurse into nested object schemas with the matching nested data\n const objectSchema = getObjectSchema(propSchema);\n if (objectSchema) {\n collectUsedCredentialFormats(objectSchema, value, formats);\n }\n }\n}\n",
|
|
11
11
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { DataPortSchema } from \"@workglow/util/schema\";\nimport { uuid4 } from \"@workglow/util\";\nimport { DATAFLOW_ALL_PORTS } from \"./Dataflow\";\nimport type { TaskGraph } from \"./TaskGraph\";\nimport type { TaskIdType } from \"../task/TaskTypes\";\nimport type {\n DataflowJson,\n JsonTaskItem,\n TaskGraphItemJson,\n TaskGraphJson,\n} from \"../task/TaskJSON\";\n\nexport interface GraphSchemaOptions {\n /**\n * When true, annotate each property with `x-source-task-id` or `x-source-task-ids`\n * to identify which task(s) the property originates from.\n */\n readonly trackOrigins?: boolean;\n}\n\n/**\n * Calculates the depth (longest path from any starting node) for each task in the graph.\n * @returns A map of task IDs to their depths\n */\nexport function calculateNodeDepths(graph: TaskGraph): Map<TaskIdType, number> {\n const depths = new Map<TaskIdType, number>();\n const tasks = graph.getTasks();\n\n for (const task of tasks) {\n depths.set(task.id, 0);\n }\n\n const sortedTasks = graph.topologicallySortedNodes();\n\n for (const task of sortedTasks) {\n const currentDepth = depths.get(task.id) || 0;\n const targetTasks = graph.getTargetTasks(task.id);\n\n for (const targetTask of targetTasks) {\n const targetDepth = depths.get(targetTask.id) || 0;\n depths.set(targetTask.id, Math.max(targetDepth, currentDepth + 1));\n }\n }\n\n return depths;\n}\n\n/**\n * Computes the input schema for a graph by examining root tasks (no incoming edges)\n * and non-root tasks with unsatisfied required inputs.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphInputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n const tasks = graph.getTasks();\n const startingNodes = tasks.filter((task) => graph.getSourceDataflows(task.id).length === 0);\n\n // Collect all properties from root tasks\n for (const task of startingNodes) {\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") {\n if (taskInputSchema === false) {\n continue;\n }\n if (taskInputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskInputSchema.properties || {};\n\n for (const [inputName, inputProp] of Object.entries(taskProperties)) {\n if (!properties[inputName]) {\n properties[inputName] = inputProp;\n\n if (taskInputSchema.required && taskInputSchema.required.includes(inputName)) {\n required.push(inputName);\n }\n\n if (trackOrigins) {\n propertyOrigins[inputName] = [task.id];\n }\n } else if (trackOrigins) {\n propertyOrigins[inputName].push(task.id);\n }\n }\n }\n\n // For non-root tasks, collect only REQUIRED properties not satisfied by dataflows.\n const sourceIds = new Set(startingNodes.map((t) => t.id));\n for (const task of tasks) {\n if (sourceIds.has(task.id)) continue;\n\n const taskInputSchema = task.inputSchema();\n if (typeof taskInputSchema === \"boolean\") continue;\n\n const requiredKeys = new Set<string>((taskInputSchema.required as string[] | undefined) || []);\n if (requiredKeys.size === 0) continue;\n\n const connectedPorts = new Set(\n graph.getSourceDataflows(task.id).map((df) => df.targetTaskPortId)\n );\n\n for (const key of requiredKeys) {\n if (connectedPorts.has(key)) continue;\n if (properties[key]) {\n // Property already collected — track additional origin\n if (trackOrigins) {\n propertyOrigins[key].push(task.id);\n }\n continue;\n }\n\n // Skip if the task already has a default value for this property\n if (task.defaults && task.defaults[key] !== undefined) continue;\n\n const prop = (taskInputSchema.properties || {})[key];\n if (!prop || typeof prop === \"boolean\") continue;\n\n properties[key] = prop;\n if (!required.includes(key)) {\n required.push(key);\n }\n\n if (trackOrigins) {\n propertyOrigins[key] = [task.id];\n }\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as const satisfies DataPortSchema;\n}\n\n/**\n * Computes the output schema for a graph by examining leaf tasks (no outgoing edges)\n * at the maximum depth level.\n *\n * When `options.trackOrigins` is true, each property is annotated with\n * `x-source-task-id` (single origin) or `x-source-task-ids` (multiple origins).\n */\nexport function computeGraphOutputSchema(\n graph: TaskGraph,\n options?: GraphSchemaOptions\n): DataPortSchema {\n const trackOrigins = options?.trackOrigins ?? false;\n const properties: Record<string, any> = {};\n const required: string[] = [];\n // Track which task IDs contribute each property name\n const propertyOrigins: Record<string, TaskIdType[]> = {};\n\n // Find all ending nodes (nodes with no outgoing dataflows)\n const tasks = graph.getTasks();\n const endingNodes = tasks.filter((task) => graph.getTargetDataflows(task.id).length === 0);\n\n // Calculate depths for all nodes\n const depths = calculateNodeDepths(graph);\n\n // Find the maximum depth among ending nodes\n const maxDepth = Math.max(...endingNodes.map((task) => depths.get(task.id) || 0));\n\n // Filter ending nodes to only those at the maximum depth (last level)\n const lastLevelNodes = endingNodes.filter((task) => depths.get(task.id) === maxDepth);\n\n // Count how many ending nodes produce each property\n const propertyCount: Record<string, number> = {};\n const propertySchema: Record<string, any> = {};\n\n for (const task of lastLevelNodes) {\n const taskOutputSchema = task.outputSchema();\n if (typeof taskOutputSchema === \"boolean\") {\n if (taskOutputSchema === false) {\n continue;\n }\n if (taskOutputSchema === true) {\n properties[DATAFLOW_ALL_PORTS] = {};\n continue;\n }\n }\n const taskProperties = taskOutputSchema.properties || {};\n\n for (const [outputName, outputProp] of Object.entries(taskProperties)) {\n propertyCount[outputName] = (propertyCount[outputName] || 0) + 1;\n if (!propertySchema[outputName]) {\n propertySchema[outputName] = outputProp;\n }\n if (trackOrigins) {\n if (!propertyOrigins[outputName]) {\n propertyOrigins[outputName] = [task.id];\n } else {\n propertyOrigins[outputName].push(task.id);\n }\n }\n }\n }\n\n // Build the final schema: properties produced by multiple nodes become arrays\n for (const [outputName] of Object.entries(propertyCount)) {\n const outputProp = propertySchema[outputName];\n\n if (lastLevelNodes.length === 1) {\n properties[outputName] = outputProp;\n } else {\n properties[outputName] = {\n type: \"array\",\n items: outputProp as any,\n };\n }\n }\n\n // Apply origin annotations\n if (trackOrigins) {\n for (const [propName, origins] of Object.entries(propertyOrigins)) {\n const prop = properties[propName];\n if (!prop || typeof prop === \"boolean\") continue;\n if (origins.length === 1) {\n properties[propName] = { ...prop, \"x-source-task-id\": origins[0] };\n } else {\n properties[propName] = { ...prop, \"x-source-task-ids\": origins };\n }\n }\n }\n\n return {\n type: \"object\",\n properties,\n ...(required.length > 0 ? { required } : {}),\n additionalProperties: false,\n } as DataPortSchema;\n}\n\n// ========================================================================\n// Boundary Node Injection\n// ========================================================================\n\n/**\n * Strips `x-source-task-id` and `x-source-task-ids` annotations from schema properties.\n */\nfunction stripOriginAnnotations(schema: DataPortSchema): DataPortSchema {\n if (typeof schema === \"boolean\" || !schema || typeof schema !== \"object\") return schema;\n const properties = schema.properties;\n if (!properties) return schema;\n\n const strippedProperties: Record<string, any> = {};\n for (const [key, prop] of Object.entries(properties)) {\n if (!prop || typeof prop !== \"object\") {\n strippedProperties[key] = prop;\n continue;\n }\n const {\n \"x-source-task-id\": _id,\n \"x-source-task-ids\": _ids,\n ...rest\n } = prop as Record<string, any>;\n strippedProperties[key] = rest;\n }\n\n return { ...schema, properties: strippedProperties } as DataPortSchema;\n}\n\n/**\n * Extracts origin task IDs from a schema property's `x-source-task-id` or `x-source-task-ids`.\n */\nfunction getOriginTaskIds(prop: Record<string, any>): TaskIdType[] {\n if (prop[\"x-source-task-ids\"]) {\n return prop[\"x-source-task-ids\"] as TaskIdType[];\n }\n if (prop[\"x-source-task-id\"] !== undefined) {\n return [prop[\"x-source-task-id\"] as TaskIdType];\n }\n return [];\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a TaskGraphJson.\n * The boundary nodes represent the graph's external interface.\n *\n * InputTask is placed first in the tasks array, OutputTask last.\n * Per-property dataflows connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToGraphJson(json: TaskGraphJson, graph: TaskGraph): TaskGraphJson {\n const hasInputTask = json.tasks.some((t) => t.type === \"InputTask\");\n const hasOutputTask = json.tasks.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return json;\n }\n\n const inputSchema = !hasInputTask\n ? computeGraphInputSchema(graph, { trackOrigins: true })\n : undefined;\n const outputSchema = !hasOutputTask\n ? computeGraphOutputSchema(graph, { trackOrigins: true })\n : undefined;\n\n const prependTasks: TaskGraphItemJson[] = [];\n const appendTasks: TaskGraphItemJson[] = [];\n const inputDataflows: DataflowJson[] = [];\n const outputDataflows: DataflowJson[] = [];\n\n if (!hasInputTask && inputSchema) {\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependTasks.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Create per-property dataflows from InputTask to origin tasks\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n inputDataflows.push({\n sourceTaskId: inputTaskId,\n sourceTaskPortId: propName,\n targetTaskId: originId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n if (!hasOutputTask && outputSchema) {\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n appendTasks.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n });\n\n // Create per-property dataflows from origin tasks to OutputTask\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n outputDataflows.push({\n sourceTaskId: originId,\n sourceTaskPortId: propName,\n targetTaskId: outputTaskId,\n targetTaskPortId: propName,\n });\n }\n }\n }\n }\n\n return {\n tasks: [...prependTasks, ...json.tasks, ...appendTasks],\n dataflows: [...inputDataflows, ...json.dataflows, ...outputDataflows],\n };\n}\n\n/**\n * Adds synthetic InputTask and OutputTask boundary nodes to a dependency JSON items array.\n * Per-property dependencies connect them to the origin tasks using origin tracking annotations.\n */\nexport function addBoundaryNodesToDependencyJson(\n items: JsonTaskItem[],\n graph: TaskGraph\n): JsonTaskItem[] {\n const hasInputTask = items.some((t) => t.type === \"InputTask\");\n const hasOutputTask = items.some((t) => t.type === \"OutputTask\");\n\n // Skip entirely if both boundary tasks already exist\n if (hasInputTask && hasOutputTask) {\n return items;\n }\n\n const prependItems: JsonTaskItem[] = [];\n const appendItems: JsonTaskItem[] = [];\n\n if (!hasInputTask) {\n const inputSchema = computeGraphInputSchema(graph, { trackOrigins: true });\n const inputTaskId = uuid4();\n const strippedInputSchema = stripOriginAnnotations(inputSchema);\n\n prependItems.push({\n id: inputTaskId,\n type: \"InputTask\",\n config: {\n inputSchema: strippedInputSchema,\n outputSchema: strippedInputSchema,\n },\n });\n\n // Build dependencies for items that receive data from InputTask\n if (typeof inputSchema !== \"boolean\" && inputSchema.properties) {\n for (const [propName, prop] of Object.entries(inputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n for (const originId of origins) {\n const targetItem = items.find((item) => item.id === originId);\n if (!targetItem) continue;\n if (!targetItem.dependencies) {\n targetItem.dependencies = {};\n }\n const existing = targetItem.dependencies[propName];\n const dep = { id: inputTaskId, output: propName };\n if (!existing) {\n targetItem.dependencies[propName] = dep;\n } else if (Array.isArray(existing)) {\n existing.push(dep);\n } else {\n targetItem.dependencies[propName] = [existing, dep];\n }\n }\n }\n }\n }\n\n if (!hasOutputTask) {\n const outputSchema = computeGraphOutputSchema(graph, { trackOrigins: true });\n const outputTaskId = uuid4();\n const strippedOutputSchema = stripOriginAnnotations(outputSchema);\n\n // Build dependencies for OutputTask from origin tasks\n const outputDependencies: JsonTaskItem[\"dependencies\"] = {};\n if (typeof outputSchema !== \"boolean\" && outputSchema.properties) {\n for (const [propName, prop] of Object.entries(outputSchema.properties)) {\n if (!prop || typeof prop === \"boolean\") continue;\n const origins = getOriginTaskIds(prop as Record<string, any>);\n if (origins.length === 1) {\n outputDependencies[propName] = { id: origins[0], output: propName };\n } else if (origins.length > 1) {\n outputDependencies[propName] = origins.map((id) => ({ id, output: propName }));\n }\n }\n }\n\n appendItems.push({\n id: outputTaskId,\n type: \"OutputTask\",\n config: {\n inputSchema: strippedOutputSchema,\n outputSchema: strippedOutputSchema,\n },\n ...(Object.keys(outputDependencies).length > 0 ? { dependencies: outputDependencies } : {}),\n });\n }\n\n return [...prependItems, ...items, ...appendItems];\n}\n",
|
|
12
12
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { EventEmitter, ServiceRegistry, uuid4 } from \"@workglow/util\";\nimport type { ResourceScope } from \"@workglow/util\";\nimport { DirectedAcyclicGraph } from \"@workglow/util/graph\";\nimport { TaskOutputRepository } from \"../storage/TaskOutputRepository\";\nimport type { ITask } from \"../task/ITask\";\nimport type { StreamEvent } from \"../task/StreamTypes\";\nimport type { TaskEntitlements } from \"../task/TaskEntitlements\";\nimport type { JsonTaskItem, TaskGraphJson, TaskGraphJsonOptions } from \"../task/TaskJSON\";\nimport type { TaskIdType, TaskInput, TaskOutput, TaskStatus } from \"../task/TaskTypes\";\nimport type { PipeFunction } from \"./Conversions\";\nimport { ensureTask } from \"./Conversions\";\nimport type { DataflowIdType } from \"./Dataflow\";\nimport { Dataflow } from \"./Dataflow\";\nimport { computeGraphEntitlements } from \"./GraphEntitlementUtils\";\nimport { addBoundaryNodesToDependencyJson, addBoundaryNodesToGraphJson } from \"./GraphSchemaUtils\";\nimport type { ITaskGraph } from \"./ITaskGraph\";\nimport {\n EventTaskGraphToDagMapping,\n GraphEventDagEvents,\n GraphEventDagParameters,\n TaskGraphEventListener,\n TaskGraphEvents,\n TaskGraphEventStatusParameters,\n TaskGraphStatusEvents,\n TaskGraphStatusListeners,\n} from \"./TaskGraphEvents\";\nimport type { GraphResultArray } from \"./TaskGraphRunner\";\nimport { CompoundMergeStrategy, GraphResult, TaskGraphRunner } from \"./TaskGraphRunner\";\n\n/**\n * Configuration for running a task graph\n */\nexport interface TaskGraphRunConfig {\n /** Optional output cache to use for this task graph */\n outputCache?: TaskOutputRepository | boolean;\n /** Optional signal to abort the task graph */\n parentSignal?: AbortSignal;\n /** Optional service registry to use for this task graph (creates child from global if not provided) */\n registry?: ServiceRegistry;\n /**\n * When true, streaming leaf tasks (no outgoing edges) accumulate their full\n * output so the workflow return value is complete. Defaults to true.\n * Pass false for subgraph runs where the parent handles streaming via\n * subscriptions and does not rely on the return value for stream data.\n */\n accumulateLeafOutputs?: boolean;\n /**\n * Maximum time in milliseconds for the entire graph execution.\n * When exceeded, all in-progress tasks are aborted and a TaskTimeoutError is thrown.\n */\n timeout?: number;\n /**\n * Maximum number of tasks allowed in the graph. Validated before execution starts.\n * Defaults to no limit. Set this to prevent runaway graph construction.\n */\n maxTasks?: number;\n /**\n * When true, check entitlements via the registered IEntitlementEnforcer before\n * graph execution begins. Throws TaskEntitlementError if any required (non-optional)\n * entitlements are denied. Default: false.\n */\n enforceEntitlements?: boolean;\n /**\n * Resource scope for collecting heavyweight resource disposers during graph execution.\n * Threaded to all tasks via IExecuteContext. The caller controls disposal.\n */\n resourceScope?: ResourceScope;\n}\n\nexport interface TaskGraphRunReactiveConfig extends Omit<\n TaskGraphRunConfig,\n \"enforceEntitlements\" | \"timeout\"\n> {\n /** Optional service registry to use for this task graph */\n registry?: ServiceRegistry;\n}\n\nclass TaskGraphDAG extends DirectedAcyclicGraph<\n ITask<any, any, any>,\n Dataflow,\n TaskIdType,\n DataflowIdType\n> {\n constructor() {\n super(\n (task: ITask<any, any, any>) => task.id,\n (dataflow: Dataflow) => dataflow.id\n );\n }\n}\n\ninterface TaskGraphConstructorConfig {\n outputCache?: TaskOutputRepository;\n dag?: TaskGraphDAG;\n}\n\n/**\n * Represents a task graph, a directed acyclic graph of tasks and data flows\n */\nexport class TaskGraph implements ITaskGraph {\n /** Optional output cache to use for this task graph */\n public outputCache?: TaskOutputRepository;\n\n /**\n * Constructor for TaskGraph\n * @param config Configuration for the task graph\n */\n constructor({ outputCache, dag }: TaskGraphConstructorConfig = {}) {\n this.outputCache = outputCache;\n this._dag = dag || new TaskGraphDAG();\n }\n\n private _dag: TaskGraphDAG;\n\n private _runner: TaskGraphRunner | undefined;\n public get runner(): TaskGraphRunner {\n if (!this._runner) {\n this._runner = new TaskGraphRunner(this, this.outputCache);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Public methods\n // ========================================================================\n\n /**\n * Runs the task graph\n * @param config Configuration for the graph run\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public run<ExecuteOutput extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<ExecuteOutput>> {\n return this.runner.runGraph<ExecuteOutput>(input, {\n outputCache: config?.outputCache || this.outputCache,\n parentSignal: config?.parentSignal || undefined,\n accumulateLeafOutputs: config?.accumulateLeafOutputs,\n registry: config?.registry,\n timeout: config?.timeout,\n maxTasks: config?.maxTasks,\n resourceScope: config?.resourceScope,\n });\n }\n\n /**\n * Runs the task graph reactively\n * @returns A promise that resolves when all tasks are complete\n * @throws TaskError if any tasks have failed\n */\n public runReactive<Output extends TaskOutput>(\n input: TaskInput = {} as TaskInput,\n config: TaskGraphRunConfig = {}\n ): Promise<GraphResultArray<Output>> {\n return this.runner.runGraphReactive<Output>(input, config);\n }\n\n /**\n * Merges the execute output to the run output\n * @param results The execute output\n * @param compoundMerge The compound merge strategy to use\n * @returns The run output\n */\n\n public mergeExecuteOutputsToRunOutput<\n ExecuteOutput extends TaskOutput,\n Merge extends CompoundMergeStrategy = CompoundMergeStrategy,\n >(\n results: GraphResultArray<ExecuteOutput>,\n compoundMerge: Merge\n ): GraphResult<ExecuteOutput, Merge> {\n return this.runner.mergeExecuteOutputsToRunOutput(results, compoundMerge);\n }\n\n /**\n * Aborts the task graph\n */\n public abort() {\n this.runner.abort();\n }\n\n /**\n * Disables the task graph\n */\n public async disable() {\n await this.runner.disable();\n }\n\n /**\n * Retrieves a task from the task graph by its id\n * @param id The id of the task to retrieve\n * @returns The task with the given id, or undefined if not found\n */\n public getTask(id: TaskIdType): ITask<any, any, any> | undefined {\n return this._dag.getNode(id);\n }\n\n /**\n * Retrieves all tasks in the task graph\n * @returns An array of tasks in the task graph\n */\n public getTasks(): ITask<any, any, any>[] {\n return this._dag.getNodes();\n }\n\n /**\n * Retrieves all tasks in the task graph topologically sorted\n * @returns An array of tasks in the task graph topologically sorted\n */\n public topologicallySortedNodes(): ITask<any, any, any>[] {\n return this._dag.topologicallySortedNodes();\n }\n\n /**\n * Adds a task to the task graph\n * @param task The task to add\n * @returns The current task graph\n */\n public addTask(fn: PipeFunction<any, any>, config?: any): unknown;\n public addTask(task: ITask<any, any, any>): unknown;\n public addTask(task: ITask<any, any, any> | PipeFunction<any, any>, config?: any): unknown {\n return this._dag.addNode(ensureTask(task, config));\n }\n\n /**\n * Adds multiple tasks to the task graph\n * @param tasks The tasks to add\n * @returns The current task graph\n */\n public addTasks(tasks: PipeFunction<any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[]): unknown[];\n public addTasks(tasks: ITask<any, any, any>[] | PipeFunction<any, any>[]): unknown[] {\n return this._dag.addNodes(tasks.map(ensureTask));\n }\n\n /**\n * Adds a data flow to the task graph\n * @param dataflow The data flow to add\n * @returns The current task graph\n */\n public addDataflow(dataflow: Dataflow) {\n return this._dag.addEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow);\n }\n\n /**\n * Adds multiple data flows to the task graph\n * @param dataflows The data flows to add\n * @returns The current task graph\n */\n public addDataflows(dataflows: Dataflow[]) {\n const addedEdges = dataflows.map<[s: unknown, t: unknown, e: Dataflow]>((edge) => {\n return [edge.sourceTaskId, edge.targetTaskId, edge];\n });\n return this._dag.addEdges(addedEdges);\n }\n\n /**\n * Retrieves a data flow from the task graph by its id\n * @param id The id of the data flow to retrieve\n * @returns The data flow with the given id, or undefined if not found\n */\n public getDataflow(id: DataflowIdType): Dataflow | undefined {\n for (const [, , edge] of this._dag.getEdges()) {\n if (edge.id === id) {\n return edge;\n }\n }\n return undefined;\n }\n\n /**\n * Retrieves all data flows in the task graph\n * @returns An array of data flows in the task graph\n */\n public getDataflows(): Dataflow[] {\n return this._dag.getEdges().map((edge) => edge[2]);\n }\n\n /**\n * Removes a data flow from the task graph\n * @param dataflow The data flow to remove\n * @returns The current task graph\n */\n public removeDataflow(dataflow: Dataflow) {\n return this._dag.removeEdge(dataflow.sourceTaskId, dataflow.targetTaskId, dataflow.id);\n }\n\n /**\n * Retrieves the data flows that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of data flows that are sources of the given task\n */\n public getSourceDataflows(taskId: unknown): Dataflow[] {\n return this._dag.inEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the data flows that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of data flows that are targets of the given task\n */\n public getTargetDataflows(taskId: unknown): Dataflow[] {\n return this._dag.outEdges(taskId).map(([, , dataflow]) => dataflow);\n }\n\n /**\n * Retrieves the tasks that are sources of a given task\n * @param taskId The id of the task to retrieve sources for\n * @returns An array of tasks that are sources of the given task\n */\n public getSourceTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getSourceDataflows(taskId).map((dataflow) => this.getTask(dataflow.sourceTaskId)!);\n }\n\n /**\n * Retrieves the tasks that are targets of a given task\n * @param taskId The id of the task to retrieve targets for\n * @returns An array of tasks that are targets of the given task\n */\n public getTargetTasks(taskId: unknown): ITask<any, any, any>[] {\n return this.getTargetDataflows(taskId).map((dataflow) => this.getTask(dataflow.targetTaskId)!);\n }\n\n /**\n * Removes a task from the task graph\n * @param taskId The id of the task to remove\n * @returns The current task graph\n */\n public removeTask(taskId: unknown) {\n return this._dag.removeNode(taskId);\n }\n\n public resetGraph() {\n this.runner.resetGraph(this, uuid4());\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns A TaskGraphJson object representing the tasks and dataflows\n */\n public toJSON(options?: TaskGraphJsonOptions): TaskGraphJson {\n const tasks = this.getTasks().map((node) => node.toJSON(options));\n const dataflows = this.getDataflows().map((df) => df.toJSON());\n let json: TaskGraphJson = {\n tasks,\n dataflows,\n };\n if (options?.withBoundaryNodes) {\n json = addBoundaryNodesToGraphJson(json, this);\n }\n return json;\n }\n\n /**\n * Converts the task graph to a JSON format suitable for dependency tracking\n * @param options Options controlling serialization (e.g., boundary nodes)\n * @returns An array of JsonTaskItem objects, each representing a task and its dependencies\n */\n public toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem[] {\n const tasks = this.getTasks().flatMap((node) => node.toDependencyJSON(options));\n this.getDataflows().forEach((df) => {\n const target = tasks.find((node) => node.id === df.targetTaskId)!;\n if (!target.dependencies) {\n target.dependencies = {};\n }\n const targetDeps = target.dependencies[df.targetTaskPortId];\n if (!targetDeps) {\n target.dependencies[df.targetTaskPortId] = {\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n };\n } else {\n if (Array.isArray(targetDeps)) {\n targetDeps.push({\n id: df.sourceTaskId,\n output: df.sourceTaskPortId,\n });\n } else {\n target.dependencies[df.targetTaskPortId] = [\n targetDeps,\n { id: df.sourceTaskId, output: df.sourceTaskPortId },\n ];\n }\n }\n });\n if (options?.withBoundaryNodes) {\n return addBoundaryNodesToDependencyJson(tasks, this);\n }\n return tasks;\n }\n\n // ========================================================================\n // Event handling\n // ========================================================================\n\n /**\n * Event emitter for task lifecycle events\n */\n public get events(): EventEmitter<TaskGraphStatusListeners> {\n if (!this._events) {\n this._events = new EventEmitter<TaskGraphStatusListeners>();\n }\n return this._events;\n }\n protected _events: EventEmitter<TaskGraphStatusListeners> | undefined;\n\n /**\n * Subscribes to an event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n * @returns a function to unsubscribe from the event\n */\n public subscribe<Event extends TaskGraphEvents>(\n name: Event,\n fn: TaskGraphEventListener<Event>\n ): () => void {\n this.on(name, fn);\n return () => this.off(name, fn);\n }\n\n /**\n * Subscribes to status changes on all tasks (existing and future)\n * @param callback - Function called when any task's status changes\n * @param callback.taskId - The ID of the task whose status changed\n * @param callback.status - The new status of the task\n * @returns a function to unsubscribe from all task status events\n */\n public subscribeToTaskStatus(\n callback: (taskId: TaskIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"status\", (status) => {\n callback(task.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to progress updates on all tasks (existing and future)\n * @param callback - Function called when any task reports progress\n * @param callback.taskId - The ID of the task reporting progress\n * @param callback.progress - The progress value (0-100)\n * @param callback.message - Optional progress message\n * @param callback.args - Additional arguments passed with the progress update\n * @returns a function to unsubscribe from all task progress events\n */\n public subscribeToTaskProgress(\n callback: (taskId: TaskIdType, progress: number, message?: string, ...args: any[]) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to progress events on all existing tasks\n const tasks = this.getTasks();\n tasks.forEach((task) => {\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n });\n\n const handleTaskAdded = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n\n const unsub = task.subscribe(\"progress\", (progress, message, ...args) => {\n callback(task.id, progress, message, ...args);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"task_added\", handleTaskAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to status changes on all dataflows (existing and future)\n * @param callback - Function called when any dataflow's status changes\n * @param callback.dataflowId - The ID of the dataflow whose status changed\n * @param callback.status - The new status of the dataflow\n * @returns a function to unsubscribe from all dataflow status events\n */\n public subscribeToDataflowStatus(\n callback: (dataflowId: DataflowIdType, status: TaskStatus) => void\n ): () => void {\n const unsubscribes: (() => void)[] = [];\n\n // Subscribe to status events on all existing dataflows\n const dataflows = this.getDataflows();\n dataflows.forEach((dataflow) => {\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n });\n\n const handleDataflowAdded = (dataflowId: DataflowIdType) => {\n const dataflow = this.getDataflow(dataflowId);\n if (!dataflow || typeof dataflow.subscribe !== \"function\") return;\n\n const unsub = dataflow.subscribe(\"status\", (status) => {\n callback(dataflow.id, status);\n });\n unsubscribes.push(unsub);\n };\n\n const graphUnsub = this.subscribe(\"dataflow_added\", handleDataflowAdded);\n unsubscribes.push(graphUnsub);\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to streaming events on the task graph.\n * Listens for task_stream_start, task_stream_chunk, and task_stream_end\n * events emitted by the TaskGraphRunner during streaming task execution.\n *\n * @param callbacks - Object with optional callbacks for each streaming event\n * @returns a function to unsubscribe from all streaming events\n */\n public subscribeToTaskStreaming(callbacks: {\n onStreamStart?: (taskId: TaskIdType) => void;\n onStreamChunk?: (taskId: TaskIdType, event: StreamEvent) => void;\n onStreamEnd?: (taskId: TaskIdType, output: Record<string, any>) => void;\n }): () => void {\n const unsubscribes: (() => void)[] = [];\n\n if (callbacks.onStreamStart) {\n const unsub = this.subscribe(\"task_stream_start\", callbacks.onStreamStart);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamChunk) {\n const unsub = this.subscribe(\"task_stream_chunk\", callbacks.onStreamChunk);\n unsubscribes.push(unsub);\n }\n\n if (callbacks.onStreamEnd) {\n const unsub = this.subscribe(\"task_stream_end\", callbacks.onStreamEnd);\n unsubscribes.push(unsub);\n }\n\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n };\n }\n\n /**\n * Subscribes to entitlement changes on all tasks (existing and future).\n * When any task's entitlements change, the graph recomputes and emits its own\n * `entitlementChange` event. Structural changes (task_added, task_removed) also trigger.\n *\n * @param callback - Function called with the aggregated entitlements whenever they change\n * @returns a function to unsubscribe from all entitlement events\n */\n public subscribeToTaskEntitlements(\n callback: (entitlements: TaskEntitlements) => void\n ): () => void {\n const globalUnsubs: (() => void)[] = [];\n const taskUnsubs = new Map<TaskIdType, () => void>();\n\n const emitChange = () => {\n const entitlements = computeGraphEntitlements(this);\n this.emit(\"entitlementChange\", entitlements);\n callback(entitlements);\n };\n\n const subscribeTask = (taskId: TaskIdType) => {\n const task = this.getTask(taskId);\n if (!task || typeof task.subscribe !== \"function\") return;\n const unsub = task.subscribe(\"entitlementChange\", () => emitChange());\n taskUnsubs.set(taskId, unsub);\n };\n\n // Subscribe to entitlementChange events on all existing tasks\n for (const task of this.getTasks()) {\n subscribeTask(task.id);\n }\n\n // Emit the initial state immediately so subscribers don't miss the current entitlements\n emitChange();\n\n // Subscribe to new tasks being added\n globalUnsubs.push(\n this.subscribe(\"task_added\", (taskId: TaskIdType) => {\n subscribeTask(taskId);\n emitChange();\n })\n );\n\n globalUnsubs.push(\n this.subscribe(\"task_removed\", (taskId: TaskIdType) => {\n const unsub = taskUnsubs.get(taskId);\n if (unsub) {\n unsub();\n taskUnsubs.delete(taskId);\n }\n emitChange();\n })\n );\n\n return () => {\n globalUnsubs.forEach((unsub) => unsub());\n taskUnsubs.forEach((unsub) => unsub());\n taskUnsubs.clear();\n };\n }\n\n /**\n * Registers an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n on<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.on(dagEvent, fn as Parameters<typeof this._dag.on>[1]);\n }\n return this.events.on(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Removes an event listener for the specified event\n * @param name - The event name to listen for\n * @param fn - The callback function to execute when the event occurs\n */\n off<Event extends TaskGraphEvents>(name: Event, fn: TaskGraphEventListener<Event>) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe cast: TaskGraph dag events (task_added, etc.) have the same signature as\n // the underlying DAG events (node-added, etc.) - both pass IDs, not full objects\n return this._dag.off(dagEvent, fn as Parameters<typeof this._dag.off>[1]);\n }\n return this.events.off(\n name as TaskGraphStatusEvents,\n fn as TaskGraphEventListener<TaskGraphStatusEvents>\n );\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n emit<E extends GraphEventDagEvents>(name: E, ...args: GraphEventDagParameters<E>): void;\n emit<E extends TaskGraphStatusEvents>(name: E, ...args: TaskGraphEventStatusParameters<E>): void;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n emit(name: string, ...args: any[]): void {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n if (dagEvent) {\n // Safe: overload signatures guarantee correct arg types at call sites\n return (this.emit_dag as Function).call(this, name, ...args);\n } else {\n return (this.emit_local as Function).call(this, name, ...args);\n }\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_local<Event extends TaskGraphStatusEvents>(\n name: Event,\n ...args: TaskGraphEventStatusParameters<Event>\n ) {\n return this.events?.emit(name, ...args);\n }\n\n /**\n * Emits an event for the specified event\n * @param name - The event name to emit\n * @param args - The arguments to pass to the event listener\n */\n protected emit_dag<Event extends GraphEventDagEvents>(\n name: Event,\n ...args: GraphEventDagParameters<Event>\n ) {\n const dagEvent = EventTaskGraphToDagMapping[name as keyof typeof EventTaskGraphToDagMapping];\n // Safe cast: GraphEventDagParameters matches the DAG's emit parameters (both are ID-based)\n return this._dag.emit(dagEvent, ...(args as unknown as [unknown]));\n }\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nfunction serialGraphEdges(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): Dataflow[] {\n const edges: Dataflow[] = [];\n for (let i = 0; i < tasks.length - 1; i++) {\n edges.push(new Dataflow(tasks[i].id, inputHandle, tasks[i + 1].id, outputHandle));\n }\n return edges;\n}\n\n/**\n * Super simple helper if you know the input and output handles, and there is only one each\n *\n * @param tasks\n * @param inputHandle\n * @param outputHandle\n * @returns\n */\nexport function serialGraph(\n tasks: ITask<any, any, any>[],\n inputHandle: string,\n outputHandle: string\n): TaskGraph {\n const graph = new TaskGraph();\n graph.addTasks(tasks);\n graph.addDataflows(serialGraphEdges(tasks, inputHandle, outputHandle));\n return graph;\n}\n",
|
|
13
13
|
"/**\n * @license\n * Copyright 2025 Steven Roussey <sroussey@gmail.com>\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { getLogger } from \"@workglow/util\";\nimport type { DataPortSchema, SchemaNode } from \"@workglow/util/schema\";\nimport { compileSchema } from \"@workglow/util/schema\";\nimport { computeGraphEntitlements } from \"../task-graph/GraphEntitlementUtils\";\nimport { computeGraphInputSchema, computeGraphOutputSchema } from \"../task-graph/GraphSchemaUtils\";\nimport { TaskGraph } from \"../task-graph/TaskGraph\";\nimport { CompoundMergeStrategy, PROPERTY_ARRAY } from \"../task-graph/TaskGraphRunner\";\nimport type { CreateLoopWorkflow } from \"../task-graph/Workflow\";\nimport { GraphAsTaskRunner } from \"./GraphAsTaskRunner\";\nimport type { IExecuteContext, IRunConfig } from \"./ITask\";\nimport type { StreamEvent, StreamFinish } from \"./StreamTypes\";\nimport { Task } from \"./Task\";\nimport type { TaskEntitlements } from \"./TaskEntitlements\";\nimport type { TaskEventListener, TaskEvents } from \"./TaskEvents\";\nimport type { JsonTaskItem, TaskGraphItemJson, TaskGraphJsonOptions } from \"./TaskJSON\";\nimport type { TaskConfig, TaskInput, TaskOutput, TaskTypeName } from \"./TaskTypes\";\nimport { TaskConfigSchema } from \"./TaskTypes\";\n\nexport const graphAsTaskConfigSchema = {\n type: \"object\",\n properties: {\n ...TaskConfigSchema[\"properties\"],\n compoundMerge: { type: \"string\", \"x-ui-hidden\": true },\n },\n additionalProperties: false,\n} as const satisfies DataPortSchema;\n\nexport type GraphAsTaskConfig<Input extends TaskInput = TaskInput> = TaskConfig<Input> & {\n /** subGraph is extracted in the constructor before validation — not in the JSON schema */\n subGraph?: TaskGraph;\n compoundMerge?: CompoundMergeStrategy;\n};\n\n/**\n * A task that contains a subgraph of tasks\n */\nexport class GraphAsTask<\n Input extends TaskInput = TaskInput,\n Output extends TaskOutput = TaskOutput,\n Config extends GraphAsTaskConfig<Input> = GraphAsTaskConfig<Input>,\n> extends Task<Input, Output, Config> {\n // ========================================================================\n // Static properties - should be overridden by subclasses\n // ========================================================================\n\n public static override type: TaskTypeName = \"GraphAsTask\";\n public static override title: string = \"Group\";\n public static override description: string = \"A group of tasks that are executed together\";\n public static override category: string = \"Flow Control\";\n public static compoundMerge: CompoundMergeStrategy = PROPERTY_ARRAY;\n\n /** This task has dynamic schemas that change based on the subgraph structure */\n public static override hasDynamicSchemas: boolean = true;\n\n /** Entitlements are always dynamic — they depend on child tasks in the subgraph */\n public static override hasDynamicEntitlements: boolean = true;\n\n // ========================================================================\n // Constructor\n // ========================================================================\n\n /**\n * @param config Task configuration; `subGraph` is applied to this instance and stripped before validating config.\n * @param runConfig Runtime configuration (forwarded to {@link Task}).\n */\n constructor(config: Partial<Config> = {}, runConfig: Partial<IRunConfig> = {}) {\n const { subGraph, ...rest } = config;\n super(rest as Partial<Config>, runConfig);\n if (subGraph) {\n this.subGraph = subGraph;\n }\n this.regenerateGraph();\n }\n\n // ========================================================================\n // TaskRunner delegation - Executes and manages the task\n // ========================================================================\n\n declare _runner: GraphAsTaskRunner<Input, Output, Config>;\n\n /**\n * Task runner for handling the task execution\n */\n override get runner(): GraphAsTaskRunner<Input, Output, Config> {\n if (!this._runner) {\n this._runner = new GraphAsTaskRunner<Input, Output, Config>(this);\n }\n return this._runner;\n }\n\n // ========================================================================\n // Static to Instance conversion methods\n // ========================================================================\n\n public static override configSchema(): DataPortSchema {\n return graphAsTaskConfigSchema;\n }\n\n public get compoundMerge(): CompoundMergeStrategy {\n return this.config?.compoundMerge || (this.constructor as typeof GraphAsTask).compoundMerge;\n }\n\n public override get cacheable(): boolean {\n return (\n this.runConfig?.cacheable ??\n this.config?.cacheable ??\n ((this.constructor as typeof GraphAsTask).cacheable && !this.hasChildren())\n );\n }\n\n // ========================================================================\n // Input/Output handling\n // ========================================================================\n\n /**\n * Override inputSchema to compute it dynamically from the subgraph at runtime.\n * For root tasks (no incoming edges) all input properties are collected.\n * For non-root tasks, only REQUIRED properties that are not satisfied by\n * any internal dataflow are added — this ensures that required inputs are\n * included in the graph's input schema without pulling in every optional\n * downstream property.\n */\n public override inputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).inputSchema();\n }\n\n return computeGraphInputSchema(this.subGraph);\n }\n\n protected _inputSchemaNode: SchemaNode | undefined;\n /**\n * Gets the compiled input schema\n */\n protected override getInputSchemaNode(): SchemaNode {\n // every graph as task is different, so we need to compile the schema for each one\n if (!this._inputSchemaNode) {\n try {\n const dataPortSchema = this.inputSchema();\n const schemaNode = Task.generateInputSchemaNode(dataPortSchema);\n this._inputSchemaNode = schemaNode;\n } catch (error) {\n // If compilation fails, fall back to accepting any object structure.\n // This is a safety net for schemas that json-schema-library can't compile.\n getLogger().warn(\n `GraphAsTask \"${this.type}\" (${this.id}): Failed to compile input schema, ` +\n `falling back to permissive validation. Inputs will NOT be validated.`,\n { error, taskType: this.type, taskId: this.id }\n );\n this._inputSchemaNode = compileSchema({});\n }\n }\n return this._inputSchemaNode!;\n }\n\n /**\n\n * Override outputSchema to compute it dynamically from the subgraph at runtime\n * The output schema depends on the compoundMerge strategy and the nodes at the last level\n */\n\n public override outputSchema(): DataPortSchema {\n // If there's no subgraph or it has no children, fall back to the static schema\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).outputSchema();\n }\n\n return computeGraphOutputSchema(this.subGraph);\n }\n\n /**\n * Override entitlements to aggregate from all tasks in the subgraph.\n */\n public override entitlements(): TaskEntitlements {\n if (!this.hasChildren()) {\n return (this.constructor as typeof Task).entitlements();\n }\n return computeGraphEntitlements(this.subGraph);\n }\n\n /**\n * Resets input data to defaults\n */\n public override resetInputData(): void {\n super.resetInputData();\n if (this.hasChildren()) {\n this.subGraph!.getTasks().forEach((node) => {\n node.resetInputData();\n });\n this.subGraph!.getDataflows().forEach((dataflow) => {\n dataflow.reset();\n });\n }\n }\n\n // ========================================================================\n // Streaming pass-through\n // ========================================================================\n\n /**\n * Stream pass-through for compound tasks: runs the subgraph and forwards\n * streaming events from ending nodes to the outer graph. Also re-yields\n * any input streams from upstream for cases where this GraphAsTask is\n * itself downstream of another streaming task.\n */\n async *executeStream(input: Input, context: IExecuteContext): AsyncIterable<StreamEvent<Output>> {\n // Forward upstream input streams first (pass-through from outer graph)\n if (context.inputStreams) {\n for (const [, stream] of context.inputStreams) {\n const reader = stream.getReader();\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value.type === \"finish\") continue;\n yield value as StreamEvent<Output>;\n }\n } finally {\n reader.releaseLock();\n }\n }\n }\n\n // Run the subgraph and forward streaming events from ending nodes\n if (this.hasChildren()) {\n const endingNodeIds = new Set<unknown>();\n const tasks = this.subGraph.getTasks();\n for (const task of tasks) {\n if (this.subGraph.getTargetDataflows(task.id).length === 0) {\n endingNodeIds.add(task.id);\n }\n }\n\n const eventQueue: StreamEvent<Output>[] = [];\n let subgraphDone = false;\n\n // Eager promise/resolver — always available for producers to signal.\n // Prevents a race where producers call a stale or undefined resolver,\n // causing the generator to hang on a promise that never resolves.\n // `isWaiting` is true only while the generator is suspended at `await notifyPromise`.\n // `hasPending` records a notification that arrived while the generator was active,\n // so the generator skips the next wait without allocating a new promise.\n let { promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>();\n let isWaiting = false;\n let hasPending = false;\n const notify = () => {\n if (isWaiting) {\n // Wake the generator and prepare a fresh deferred for the next wait.\n notifyResolve();\n ({ promise: notifyPromise, resolve: notifyResolve } = Promise.withResolvers<void>());\n isWaiting = false;\n } else {\n // Generator is still draining; skip the allocation.\n hasPending = true;\n }\n };\n\n const unsub = this.subGraph.subscribeToTaskStreaming({\n onStreamChunk: (taskId, event) => {\n if (endingNodeIds.has(taskId) && event.type !== \"finish\") {\n eventQueue.push(event as StreamEvent<Output>);\n notify();\n }\n },\n });\n\n const runPromise = this.subGraph\n .run<Output>(input, { parentSignal: context.signal, accumulateLeafOutputs: false })\n .then(\n (results) => {\n subgraphDone = true;\n notify();\n return results;\n },\n (err) => {\n subgraphDone = true;\n notify();\n throw err;\n }\n );\n\n // Yield events as they arrive from ending nodes\n while (!subgraphDone) {\n if (eventQueue.length === 0) {\n if (hasPending) {\n // A notification arrived while we were active; consume it without blocking.\n hasPending = false;\n } else {\n isWaiting = true;\n await notifyPromise;\n }\n }\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n }\n // Drain any remaining events\n while (eventQueue.length > 0) {\n yield eventQueue.shift()!;\n }\n\n unsub();\n\n const results = await runPromise;\n const mergedOutput = this.subGraph.mergeExecuteOutputsToRunOutput(\n results,\n this.compoundMerge\n ) as Output;\n yield { type: \"finish\", data: mergedOutput } as StreamFinish<Output>;\n } else {\n yield { type: \"finish\", data: input as unknown as Output } as StreamFinish<Output>;\n }\n }\n\n // ========================================================================\n // Compound task methods\n // ========================================================================\n\n /**\n * Regenerates the subtask graph and emits a \"regenerate\" event\n *\n * Subclasses should override this method to implement the actual graph\n * regeneration logic, but all they need to do is call this method to\n * emit the \"regenerate\" event.\n */\n public override regenerateGraph(): void {\n this._inputSchemaNode = undefined;\n this.events.emit(\"regenerate\");\n this.emitEntitlementChange();\n }\n\n // ========================================================================\n // SubGraph entitlement forwarding\n // ========================================================================\n\n /** Unsubscribe handle for the current subGraph entitlement subscription */\n private _entitlementUnsub: (() => void) | undefined;\n\n /**\n * Guards against re-entry while the synchronous initial emit of\n * `subscribeToTaskEntitlements` is unwinding. Without this, the initial\n * emit's callback re-reads `this.subGraph`, which would re-trigger\n * `_syncSubGraphEntitlementSubscription` before `_entitlementUnsub` has\n * been assigned and loop forever.\n */\n private _subscribingEntitlements: boolean = false;\n\n // ========================================================================\n // SubGraph entitlement subscription\n // ========================================================================\n\n /**\n * Subscribe to the subGraph's aggregated entitlement changes and forward\n * them as an entitlementChange event on this task so that the parent\n * TaskGraph / Workflow sees the update.\n */\n private _subscribeToSubGraphEntitlements(graph: TaskGraph): void {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n this._subscribingEntitlements = true;\n try {\n this._entitlementUnsub = graph.subscribeToTaskEntitlements(() => {\n this.emitEntitlementChange();\n });\n } finally {\n this._subscribingEntitlements = false;\n }\n }\n\n private _syncSubGraphEntitlementSubscription(\n graph: TaskGraph | undefined = this._subGraph\n ): void {\n if (this._subscribingEntitlements) return;\n\n if ((this._events?.listenerCount(\"entitlementChange\") ?? 0) === 0) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n return;\n }\n\n if (!graph || this._entitlementUnsub) {\n return;\n }\n\n this._subscribeToSubGraphEntitlements(graph);\n }\n\n public override subscribe<Event extends TaskEvents>(\n name: Event,\n fn: TaskEventListener<Event>\n ): () => void {\n const unsub = super.subscribe(name, fn);\n if (name !== \"entitlementChange\") {\n return unsub;\n }\n\n this._syncSubGraphEntitlementSubscription();\n\n return () => {\n unsub();\n this._syncSubGraphEntitlementSubscription();\n };\n }\n\n public override on<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.on(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override off<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.off(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override once<Event extends TaskEvents>(name: Event, fn: TaskEventListener<Event>): void {\n super.once(name, fn);\n if (name === \"entitlementChange\") {\n this._syncSubGraphEntitlementSubscription();\n }\n }\n\n public override set subGraph(subGraph: TaskGraph) {\n this._entitlementUnsub?.();\n this._entitlementUnsub = undefined;\n super.subGraph = subGraph;\n this._syncSubGraphEntitlementSubscription(subGraph);\n }\n\n override get subGraph(): TaskGraph {\n const graph = super.subGraph;\n // The base getter may have lazily created a new graph — subscribe only when needed.\n this._syncSubGraphEntitlementSubscription(graph);\n return graph;\n }\n\n // ========================================================================\n // Serialization methods\n // ========================================================================\n\n /**\n * Serializes the task and its subtasks into a format that can be stored\n * @returns The serialized task and subtasks\n */\n public override toJSON(options?: TaskGraphJsonOptions): TaskGraphItemJson {\n let json = super.toJSON(options);\n const hasChildren = this.hasChildren();\n if (hasChildren) {\n json = {\n ...json,\n merge: this.compoundMerge,\n subgraph: this.subGraph!.toJSON(options),\n };\n }\n return json;\n }\n\n /**\n * Converts the task to a JSON format suitable for dependency tracking\n * @returns The task and subtasks in JSON thats easier for humans to read\n */\n public override toDependencyJSON(options?: TaskGraphJsonOptions): JsonTaskItem {\n const json = this.toJSON(options);\n if (this.hasChildren()) {\n if (\"subgraph\" in json) {\n delete json.subgraph;\n }\n return { ...json, subtasks: this.subGraph!.toDependencyJSON(options) };\n }\n return json;\n }\n}\n\ndeclare module \"../task-graph/Workflow\" {\n interface Workflow {\n /**\n * Starts a group that wraps inner tasks in a GraphAsTask subgraph.\n * Use .endGroup() to close the group and return to the parent workflow.\n */\n group: CreateLoopWorkflow<TaskInput, TaskOutput, GraphAsTaskConfig<TaskInput>>;\n\n /**\n * Ends the group and returns to the parent workflow.\n */\n endGroup(): Workflow;\n }\n}\n\n// Prototype assignments live in Workflow.ts (bottom of file) to avoid\n// circular-dependency issues at module evaluation time.\n",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"GraphFormatScanner.d.ts","sourceRoot":"","sources":["../../src/task-graph/GraphFormatScanner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"GraphFormatScanner.d.ts","sourceRoot":"","sources":["../../src/task-graph/GraphFormatScanner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,yGAAyG;IACzG,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,8DAA8D;IAC9D,QAAQ,CAAC,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACjD;AA0BD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAanF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,UAAU,GAAG,qBAAqB,CAgBhF"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workglow/task-graph",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.10",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/workglow-dev/workglow.git",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@workglow/job-queue": "0.2.
|
|
55
|
-
"@workglow/storage": "0.2.
|
|
56
|
-
"@workglow/util": "0.2.
|
|
54
|
+
"@workglow/job-queue": "0.2.10",
|
|
55
|
+
"@workglow/storage": "0.2.10",
|
|
56
|
+
"@workglow/util": "0.2.10"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"@workglow/job-queue": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@workglow/job-queue": "0.2.
|
|
71
|
-
"@workglow/storage": "0.2.
|
|
72
|
-
"@workglow/util": "0.2.
|
|
70
|
+
"@workglow/job-queue": "0.2.10",
|
|
71
|
+
"@workglow/storage": "0.2.10",
|
|
72
|
+
"@workglow/util": "0.2.10"
|
|
73
73
|
}
|
|
74
74
|
}
|