From bee4825f41e460a777b442e687ec78adbca1923b Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:33:04 -0400 Subject: [PATCH 01/20] chore(workflow): scaffold package configuration Add package.json, tsconfig.json, tsconfig.build.json, jest.config.ts to initialize the workflow package. Signed-off-by: Vinay Singh --- packages/workflow/jest.config.ts | 21 +++++++++++++ packages/workflow/package.json | 43 +++++++++++++++++++++++++++ packages/workflow/tsconfig.build.json | 7 +++++ packages/workflow/tsconfig.json | 6 ++++ 4 files changed, 77 insertions(+) create mode 100644 packages/workflow/jest.config.ts create mode 100644 packages/workflow/package.json create mode 100644 packages/workflow/tsconfig.build.json create mode 100644 packages/workflow/tsconfig.json diff --git a/packages/workflow/jest.config.ts b/packages/workflow/jest.config.ts new file mode 100644 index 0000000000..d9328be714 --- /dev/null +++ b/packages/workflow/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from '@jest/types' + +const config: Config.InitialOptions = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '\\.(t|j)sx?$': [ + 'ts-jest', + { + tsconfig: { + isolatedModules: true, + }, + }, + ], + }, + displayName: '@credo-ts/workflow', + testMatch: ['**/?(*.)test.ts', '**/?(*.)spec.ts'], + setupFilesAfterEnv: ['./src/tests/setup.ts'], +} + +export default config diff --git a/packages/workflow/package.json b/packages/workflow/package.json new file mode 100644 index 0000000000..64064d72f0 --- /dev/null +++ b/packages/workflow/package.json @@ -0,0 +1,43 @@ +{ + "name": "@credo-ts/workflow", + "main": "src/index", + "types": "src/index", + "version": "0.5.13", + "files": ["build"], + "license": "Apache-2.0", + "publishConfig": { + "main": "build/index", + "types": "build/index", + "access": "public" + }, + "homepage": "https://github.com/openwallet-foundation/credo-ts/tree/main/packages/workflow", + "repository": { + "type": "git", + "url": "https://github.com/openwallet-foundation/credo-ts", + "directory": "packages/workflow" + }, + "scripts": { + "build": "pnpm run clean && pnpm run compile", + "clean": "rimraf ./build", + "compile": "tsc -p tsconfig.build.json", + "prepublishOnly": "pnpm run build", + "test": "jest", + "test:cov": "jest --coverage --runInBand" + }, + "dependencies": { + "@credo-ts/core": "workspace:*", + "@credo-ts/didcomm": "workspace:*", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "jmespath": "^0.16.0" + }, + "devDependencies": { + "@credo-ts/askar": "workspace:*", + "@credo-ts/node": "workspace:*", + "@openwallet-foundation/askar-nodejs": "catalog:", + "@types/jmespath": "^0.15.2", + "reflect-metadata": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/workflow/tsconfig.build.json b/packages/workflow/tsconfig.build.json new file mode 100644 index 0000000000..2b75d0adab --- /dev/null +++ b/packages/workflow/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["src/**/*"] +} diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json new file mode 100644 index 0000000000..46efe6f721 --- /dev/null +++ b/packages/workflow/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + } +} From 6529d75aff3299a9fc358885ede02a068b031c85 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:33:42 -0400 Subject: [PATCH 02/20] feat(workflow): add module entry, config, and events Introduce WorkflowModule, WorkflowModuleConfig, WorkflowEvents, and public exports via index. Signed-off-by: Vinay Singh --- packages/workflow/src/WorkflowEvents.ts | 39 +++++++ packages/workflow/src/WorkflowModule.ts | 105 ++++++++++++++++++ packages/workflow/src/WorkflowModuleConfig.ts | 22 ++++ packages/workflow/src/index.ts | 46 ++++++++ 4 files changed, 212 insertions(+) create mode 100644 packages/workflow/src/WorkflowEvents.ts create mode 100644 packages/workflow/src/WorkflowModule.ts create mode 100644 packages/workflow/src/WorkflowModuleConfig.ts create mode 100644 packages/workflow/src/index.ts diff --git a/packages/workflow/src/WorkflowEvents.ts b/packages/workflow/src/WorkflowEvents.ts new file mode 100644 index 0000000000..0b42a8bf70 --- /dev/null +++ b/packages/workflow/src/WorkflowEvents.ts @@ -0,0 +1,39 @@ +import type { BaseEvent } from '@credo-ts/core' +import type { WorkflowInstanceRecord, WorkflowInstanceStatus } from './repository/WorkflowInstanceRecord' + +export enum WorkflowEventTypes { + WorkflowInstanceStateChanged = 'WorkflowInstanceStateChanged', + WorkflowInstanceStatusChanged = 'WorkflowInstanceStatusChanged', + WorkflowInstanceCompleted = 'WorkflowInstanceCompleted', +} + +export interface WorkflowInstanceStateChangedEvent extends BaseEvent { + type: WorkflowEventTypes.WorkflowInstanceStateChanged + payload: { + instanceRecord: WorkflowInstanceRecord + previousState: string | null + newState: string + event: string + actionKey?: string + msgId?: string + } +} + +export interface WorkflowInstanceStatusChangedEvent extends BaseEvent { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged + payload: { + instanceRecord: WorkflowInstanceRecord + previousStatus: WorkflowInstanceStatus | null + newStatus: WorkflowInstanceStatus + reason?: string + } +} + +export interface WorkflowInstanceCompletedEvent extends BaseEvent { + type: WorkflowEventTypes.WorkflowInstanceCompleted + payload: { + instanceRecord: WorkflowInstanceRecord + state: string + section?: string + } +} diff --git a/packages/workflow/src/WorkflowModule.ts b/packages/workflow/src/WorkflowModule.ts new file mode 100644 index 0000000000..326b40d5c3 --- /dev/null +++ b/packages/workflow/src/WorkflowModule.ts @@ -0,0 +1,105 @@ +import type { AgentContext, DependencyManager, Module } from '@credo-ts/core' +import { AgentConfig, EventEmitter } from '@credo-ts/core' +import { + DidCommCredentialEventTypes, + DidCommCredentialState, + DidCommCredentialStateChangedEvent, + DidCommFeatureRegistry, + DidCommMessageHandlerRegistry, + DidCommProofEventTypes, + DidCommProofState, + DidCommProofStateChangedEvent, + DidCommProtocol, +} from '@credo-ts/didcomm' +import { WorkflowModuleConfig, WorkflowModuleConfigOptions } from './WorkflowModuleConfig' +import { WorkflowApi } from './api/WorkflowApi' +import { AdvanceHandler } from './protocol/handlers/AdvanceHandler' +import { CancelHandler } from './protocol/handlers/CancelHandler' +import { CompleteHandler } from './protocol/handlers/CompleteHandler' +import { PauseHandler } from './protocol/handlers/PauseHandler' +import { ProblemReportHandler } from './protocol/handlers/ProblemReportHandler' +import { PublishTemplateHandler } from './protocol/handlers/PublishTemplateHandler' +import { ResumeHandler } from './protocol/handlers/ResumeHandler' +import { StartHandler } from './protocol/handlers/StartHandler' +import { StatusHandler } from './protocol/handlers/StatusHandler' +import { WorkflowInstanceRepository } from './repository/WorkflowInstanceRepository' +import { WorkflowTemplateRepository } from './repository/WorkflowTemplateRepository' +import { WorkflowService } from './services/WorkflowService' + +export const WORKFLOW_PROTOCOL_URI = 'https://didcomm.org/workflow/1.0' +export const WORKFLOW_ROLES = ['processor', 'coordinator'] as const + +export class WorkflowModule implements Module { + public readonly api = WorkflowApi + public readonly config: WorkflowModuleConfig + + public constructor(options?: WorkflowModuleConfigOptions) { + this.config = new WorkflowModuleConfig(options) + } + + public register(dependencyManager: DependencyManager) { + dependencyManager.resolve(AgentConfig).logger.info('Registering WorkflowModule') + dependencyManager.registerInstance(WorkflowModuleConfig, this.config) + + dependencyManager.registerSingleton(WorkflowTemplateRepository) + dependencyManager.registerSingleton(WorkflowInstanceRepository) + dependencyManager.registerSingleton(WorkflowService) + dependencyManager.registerSingleton(WorkflowApi) + + dependencyManager.registerSingleton(PublishTemplateHandler) + dependencyManager.registerSingleton(StartHandler) + dependencyManager.registerSingleton(AdvanceHandler) + dependencyManager.registerSingleton(StatusHandler) + dependencyManager.registerSingleton(ProblemReportHandler) + dependencyManager.registerSingleton(PauseHandler) + dependencyManager.registerSingleton(ResumeHandler) + dependencyManager.registerSingleton(CancelHandler) + dependencyManager.registerSingleton(CompleteHandler) + } + + public async initialize(agentContext: AgentContext): Promise { + const dm = agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const features = dm.resolve(DidCommFeatureRegistry) + const handlers = dm.resolve(DidCommMessageHandlerRegistry) + logger.info('Initializing WorkflowModule - registering workflow/1.0 protocol') + try { + features.register(new DidCommProtocol({ id: WORKFLOW_PROTOCOL_URI, roles: [...WORKFLOW_ROLES] })) + } catch {} + handlers.registerMessageHandler(dm.resolve(PublishTemplateHandler)) + handlers.registerMessageHandler(dm.resolve(StartHandler)) + handlers.registerMessageHandler(dm.resolve(AdvanceHandler)) + handlers.registerMessageHandler(dm.resolve(StatusHandler)) + handlers.registerMessageHandler(dm.resolve(ProblemReportHandler)) + handlers.registerMessageHandler(dm.resolve(PauseHandler)) + handlers.registerMessageHandler(dm.resolve(ResumeHandler)) + handlers.registerMessageHandler(dm.resolve(CancelHandler)) + handlers.registerMessageHandler(dm.resolve(CompleteHandler)) + // Inbound mapping: credentials/proofs → workflow events + const events = dm.resolve(EventEmitter) + const service = dm.resolve(WorkflowService) + events.on( + DidCommCredentialEventTypes.DidCommCredentialStateChanged, + async (e) => { + const rec = e.payload.credentialExchangeRecord + const connId = rec.connectionId + if (!connId) return + if (rec.state === DidCommCredentialState.RequestReceived) { + await service.autoAdvanceByConnection(agentContext, connId, 'request_received') + } else if (rec.state === DidCommCredentialState.Done) { + await service.autoAdvanceByConnection(agentContext, connId, 'issued_ack') + } + } + ) + events.on(DidCommProofEventTypes.ProofStateChanged, async (e) => { + const rec = e.payload.proofRecord + const connId = rec.connectionId + if (!connId) return + if (rec.state === DidCommProofState.PresentationReceived) { + await service.autoAdvanceByConnection(agentContext, connId, 'presentation_received') + } else if (rec.state === DidCommProofState.Done) { + await service.autoAdvanceByConnection(agentContext, connId, 'verified_ack') + } + }) + } +} diff --git a/packages/workflow/src/WorkflowModuleConfig.ts b/packages/workflow/src/WorkflowModuleConfig.ts new file mode 100644 index 0000000000..f6368219a0 --- /dev/null +++ b/packages/workflow/src/WorkflowModuleConfig.ts @@ -0,0 +1,22 @@ +export type GuardEngine = 'jmespath' | 'cel' | 'js' + +export interface WorkflowModuleConfigOptions { + guardEngine?: GuardEngine + autoReturnExistingOnSingleton?: boolean + actionTimeoutMs?: number + enableProblemReport?: boolean +} + +export class WorkflowModuleConfig { + public readonly guardEngine: GuardEngine + public readonly autoReturnExistingOnSingleton: boolean + public readonly actionTimeoutMs: number + public readonly enableProblemReport: boolean + + public constructor(options?: WorkflowModuleConfigOptions) { + this.guardEngine = options?.guardEngine ?? 'jmespath' + this.autoReturnExistingOnSingleton = options?.autoReturnExistingOnSingleton ?? true + this.actionTimeoutMs = options?.actionTimeoutMs ?? 15000 + this.enableProblemReport = options?.enableProblemReport ?? true + } +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts new file mode 100644 index 0000000000..64deb84e81 --- /dev/null +++ b/packages/workflow/src/index.ts @@ -0,0 +1,46 @@ +export * from './WorkflowModule' +export * from './WorkflowModuleConfig' +export * from './api/WorkflowApi' +export * from './model/types' +export * from './model/TemplateValidation' + +// Engine utilities +export * from './engine/AttributePlanner' +export * from './engine/GuardEvaluator' + +// Services +export * from './services/WorkflowService' +export * from './WorkflowEvents' +export * from './protocol/WorkflowMessageTypes' + +// Repository +export * from './repository/WorkflowTemplateRecord' +export * from './repository/WorkflowTemplateRepository' +export * from './repository/WorkflowInstanceRecord' +export * from './repository/WorkflowInstanceRepository' + +// Protocol messages +export * from './protocol/messages/PublishTemplateMessage' +export * from './protocol/messages/StartMessage' +export * from './protocol/messages/AdvanceMessage' +export * from './protocol/messages/StatusRequestMessage' +export * from './protocol/messages/StatusMessage' +export * from './protocol/messages/ProblemReportMessage' +export * from './protocol/messages/CancelMessage' +export * from './protocol/messages/PauseMessage' +export * from './protocol/messages/ResumeMessage' +export * from './protocol/messages/CompleteMessage' + +// Protocol handlers +export * from './protocol/handlers/PublishTemplateHandler' +export * from './protocol/handlers/StartHandler' +export * from './protocol/handlers/AdvanceHandler' +export * from './protocol/handlers/StatusHandler' +export * from './protocol/handlers/ProblemReportHandler' +export * from './protocol/handlers/PauseHandler' +export * from './protocol/handlers/ResumeHandler' +export * from './protocol/handlers/CancelHandler' +export * from './protocol/handlers/CompleteHandler' + +// Actions +export * from './actions/ActionRegistry' From 14286a41582d8a6137d717c9c25d148b3818ef7c Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:35:10 -0400 Subject: [PATCH 03/20] feat(workflow): add models and schema validation Add core model types and template validation helpers. Signed-off-by: Vinay Singh --- .../workflow/src/model/TemplateValidation.ts | 222 ++++++++++++++++++ packages/workflow/src/model/types.ts | 103 ++++++++ 2 files changed, 325 insertions(+) create mode 100644 packages/workflow/src/model/TemplateValidation.ts create mode 100644 packages/workflow/src/model/types.ts diff --git a/packages/workflow/src/model/TemplateValidation.ts b/packages/workflow/src/model/TemplateValidation.ts new file mode 100644 index 0000000000..c3d446c0b0 --- /dev/null +++ b/packages/workflow/src/model/TemplateValidation.ts @@ -0,0 +1,222 @@ +import Ajv from 'ajv' +import addFormats from 'ajv-formats' +import type { WorkflowTemplate } from './types' + +const ajv = new Ajv({ allErrors: true, strict: false }) +addFormats(ajv) + +const schema: any = { + type: 'object', + required: ['template_id', 'version', 'title', 'instance_policy', 'states', 'transitions', 'catalog', 'actions'], + properties: { + template_id: { type: 'string', minLength: 1 }, + version: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1 }, + instance_policy: { + type: 'object', + required: ['mode'], + properties: { + mode: { enum: ['singleton_per_connection', 'multi_per_connection'] }, + multiplicity_key: { type: 'string' }, + }, + additionalProperties: false, + }, + sections: { + type: 'array', + items: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' }, order: { type: 'number' }, icon: { type: 'string' } }, + additionalProperties: true, + }, + }, + states: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['name', 'type'], + properties: { + name: { type: 'string' }, + type: { enum: ['start', 'normal', 'final'] }, + section: { type: 'string' }, + }, + additionalProperties: true, + }, + }, + transitions: { + type: 'array', + items: { + type: 'object', + required: ['from', 'to', 'on'], + properties: { + from: { type: 'string' }, + to: { type: 'string' }, + on: { type: 'string' }, + guard: { type: 'string' }, + action: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + catalog: { + type: 'object', + properties: { + credential_profiles: { + type: 'object', + additionalProperties: { + type: 'object', + required: ['cred_def_id', 'attribute_plan', 'to_ref'], + properties: { + cred_def_id: { type: 'string' }, + attribute_plan: { + type: 'object', + additionalProperties: { + anyOf: [ + { + type: 'object', + required: ['source', 'path'], + properties: { + source: { const: 'context' }, + path: { type: 'string' }, + required: { type: 'boolean' }, + }, + additionalProperties: false, + }, + { + type: 'object', + required: ['source', 'value'], + properties: { source: { const: 'static' }, value: {}, required: { type: 'boolean' } }, + additionalProperties: false, + }, + { + type: 'object', + required: ['source', 'expr'], + properties: { + source: { const: 'compute' }, + expr: { type: 'string' }, + required: { type: 'boolean' }, + }, + additionalProperties: false, + }, + ], + }, + }, + to_ref: { type: 'string' }, + options: { type: 'object', additionalProperties: true }, + }, + additionalProperties: false, + }, + }, + proof_profiles: { + type: 'object', + additionalProperties: { + type: 'object', + required: ['to_ref'], + properties: { + schema_id: { type: 'string' }, + cred_def_id: { type: 'string' }, + requested_attributes: { type: 'array', items: { type: 'string' } }, + requested_predicates: { + type: 'array', + items: { + type: 'object', + required: ['name', 'p_type', 'p_value'], + properties: { name: { type: 'string' }, p_type: { type: 'string' }, p_value: { type: 'number' } }, + additionalProperties: false, + }, + }, + to_ref: { type: 'string' }, + options: { type: 'object', additionalProperties: true }, + }, + additionalProperties: false, + }, + }, + defaults: { type: 'object' }, + }, + additionalProperties: true, + }, + actions: { + type: 'array', + items: { + type: 'object', + required: ['key', 'typeURI'], + properties: { + key: { type: 'string' }, + typeURI: { type: 'string' }, + profile_ref: { type: 'string', pattern: '^(cp|pp)\\.' }, + staticInput: {}, + }, + additionalProperties: true, + }, + }, + display_hints: { type: 'object' }, + }, + additionalProperties: false, +} + +const validate = ajv.compile(schema) + +export function validateTemplateJson(tpl: unknown) { + const ok = validate(tpl) + if (!ok) { + const msg = (validate.errors || []).map((e: any) => `${e.instancePath || 'template'} ${e.message}`).join('; ') + const err = new Error(msg) + ;(err as any).code = 'invalid_template' + throw err + } +} + +export function validateTemplateRefs(t: WorkflowTemplate) { + // Structural checks beyond schema + const stateNames = new Set(t.states.map((s) => s.name)) + if (![...t.states].some((s) => s.type === 'start')) { + const err = new Error('start state required') + ;(err as any).code = 'invalid_template' + throw err + } + for (const s of t.states) { + if (s.section && !t.sections?.some((sec) => sec.name === s.section)) { + const err = new Error(`state.section not found: ${s.section}`) + ;(err as any).code = 'invalid_template' + throw err + } + } + for (const tr of t.transitions) { + if (!stateNames.has(tr.from)) { + const err = new Error(`transition.from unknown: ${tr.from}`) + ;(err as any).code = 'invalid_template' + throw err + } + if (!stateNames.has(tr.to)) { + const err = new Error(`transition.to unknown: ${tr.to}`) + ;(err as any).code = 'invalid_template' + throw err + } + if (tr.action && !t.actions.some((a) => a.key === tr.action)) { + const err = new Error(`transition.action unknown: ${tr.action}`) + ;(err as any).code = 'invalid_template' + throw err + } + } + for (const a of t.actions) { + const pr: any = (a as any).profile_ref + if (pr) { + if (pr.startsWith('cp.')) { + const key = pr.slice(3) + if (!t.catalog?.credential_profiles || !t.catalog.credential_profiles[key]) { + const err = new Error(`catalog.cp missing: ${key}`) + ;(err as any).code = 'invalid_template' + throw err + } + } else if (pr.startsWith('pp.')) { + const key = pr.slice(3) + if (!t.catalog?.proof_profiles || !t.catalog.proof_profiles[key]) { + const err = new Error(`catalog.pp missing: ${key}`) + ;(err as any).code = 'invalid_template' + throw err + } + } + } + } +} diff --git a/packages/workflow/src/model/types.ts b/packages/workflow/src/model/types.ts new file mode 100644 index 0000000000..f570ffac1f --- /dev/null +++ b/packages/workflow/src/model/types.ts @@ -0,0 +1,103 @@ +export type InstancePolicy = { + mode: 'singleton_per_connection' | 'multi_per_connection' + multiplicity_key?: string +} + +export type SectionDef = { name: string; order?: number; icon?: string } + +export type StateDef = { name: string; type: 'start' | 'normal' | 'final'; section?: string } + +export type TransitionDef = { + from: string + to: string + on: string + guard?: string + action?: string +} + +export type AttributeSpec = + | { source: 'context'; path: string; required?: boolean } + | { source: 'static'; value: unknown; required?: boolean } + | { source: 'compute'; expr: string; required?: boolean } + +export type CredentialProfile = { + cred_def_id: string + attribute_plan: Record + to_ref: string + options?: Record +} + +export type ProofProfile = { + // Either cred_def_id or schema_id may be provided to scope restrictions + cred_def_id?: string + schema_id?: string + requested_attributes?: string[] + requested_predicates?: Array<{ name: string; p_type: string; p_value: number }> + to_ref: string + options?: Record +} + +export type Catalog = { + credential_profiles?: Record + proof_profiles?: Record + defaults?: Record +} + +export type ActionDef = + | { key: string; typeURI: string; profile_ref: string } + | { key: string; typeURI: string; staticInput?: any } + +export type DisplayHints = { + states?: Record +} + +export type WorkflowTemplate = { + template_id: string + version: string + title: string + instance_policy: InstancePolicy + sections?: SectionDef[] + states: StateDef[] + transitions: TransitionDef[] + catalog: Catalog + actions: ActionDef[] + display_hints?: DisplayHints +} + +export type Participants = Record + +export type InstanceHistoryItem = { + ts: string + event: string + from: string + to: string + actionKey?: string + msg_id?: string +} + +export type WorkflowInstanceData = { + instance_id: string + template_id: string + template_version: string + connection_id?: string + participants: Participants + state: string + section?: string + context: Record + artifacts: Record + status: 'active' | 'paused' | 'canceled' | 'completed' | 'error' + history: InstanceHistoryItem[] + multiplicityKeyValue?: string + idempotencyKeys?: string[] +} + +export const findSectionForState = (tpl: WorkflowTemplate, stateName?: string): string | undefined => { + if (!stateName) return undefined + const st = tpl.states.find((s) => s.name === stateName) + return st?.section +} + +export const transitionsFromState = (tpl: WorkflowTemplate, state: string) => + tpl.transitions.filter((t) => t.from === state) + +export const ensureArray = (arr?: T[]): T[] => (Array.isArray(arr) ? arr : []) From 428d1bdcf1557ecd46e1a40c67b4ac7762e42f45 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:09 -0400 Subject: [PATCH 04/20] feat(workflow): add engine utilities (AttributePlanner, GuardEvaluator) Provide planning and guard evaluation utilities for workflow execution. Signed-off-by: Vinay Singh --- .../workflow/src/engine/AttributePlanner.ts | 56 +++++++++++++++++++ .../workflow/src/engine/GuardEvaluator.ts | 47 ++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/workflow/src/engine/AttributePlanner.ts create mode 100644 packages/workflow/src/engine/GuardEvaluator.ts diff --git a/packages/workflow/src/engine/AttributePlanner.ts b/packages/workflow/src/engine/AttributePlanner.ts new file mode 100644 index 0000000000..31b0838748 --- /dev/null +++ b/packages/workflow/src/engine/AttributePlanner.ts @@ -0,0 +1,56 @@ +import { AttributeSpec, WorkflowInstanceData } from '../model/types' + +const isObject = (v: any) => v && typeof v === 'object' && !Array.isArray(v) + +export class AttributePlanner { + public static materialize(plan: Record, instance: WorkflowInstanceData): Record { + const out: Record = {} + for (const [key, spec] of Object.entries(plan || {})) { + let value: any + if ((spec as any).source === 'context') { + const p = (spec as any).path as string + value = AttributePlanner.getByPath(instance.context || {}, p) + } else if ((spec as any).source === 'static') { + value = (spec as any).value + } else if ((spec as any).source === 'compute') { + value = AttributePlanner.computeExpr((spec as any).expr) + } + if ((spec as any).required && (value === undefined || value === null || value === '')) { + throw Object.assign(new Error('missing_attributes'), { code: 'missing_attributes', attribute: key }) + } + if (value !== undefined) out[key] = value + } + return out + } + + private static getByPath(obj: any, path: string) { + return path.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), obj) + } + + private static computeExpr(expr: string): any { + // Limited helpers: now(), concat(a,b,...) + const helpers = { + now: () => new Date().toISOString(), + concat: (...args: any[]) => args.map((a) => (a == null ? '' : String(a))).join(''), + } + try { + const fn = new Function('now', 'concat', `return ((${expr}));`) + return fn(helpers.now, helpers.concat) + } catch { + return undefined + } + } +} + +export const deepMerge = (target: any, source: any) => { + if (!isObject(target) || !isObject(source)) return source + for (const [k, v] of Object.entries(source)) { + if (isObject(v)) { + if (!isObject(target[k])) target[k] = {} + deepMerge(target[k], v) + } else { + target[k] = v + } + } + return target +} diff --git a/packages/workflow/src/engine/GuardEvaluator.ts b/packages/workflow/src/engine/GuardEvaluator.ts new file mode 100644 index 0000000000..6deab3c8bd --- /dev/null +++ b/packages/workflow/src/engine/GuardEvaluator.ts @@ -0,0 +1,47 @@ +import jmespath from 'jmespath' +import { Participants, WorkflowInstanceData } from '../model/types' + +export type GuardEnv = { + context: Record + participants: Participants + artifacts: Record +} + +export class GuardEvaluator { + public static evalGuard( + expression: string | undefined, + env: GuardEnv, + engine: 'jmespath' | 'cel' | 'js' = 'jmespath' + ): boolean { + if (!expression) return true + try { + if (engine === 'jmespath') { + const res = jmespath.search(env as any, expression) + return !!res + } + // fallback JS for dev + const fn = new Function('context', 'participants', 'artifacts', `return (${expression});`) + return !!fn(env.context, env.participants, env.artifacts) + } catch { + return false + } + } + + public static evalValue(expression: string, env: GuardEnv, engine: 'jmespath' | 'cel' | 'js' = 'jmespath'): any { + try { + if (engine === 'jmespath') return jmespath.search(env as any, expression) + const fn = new Function('context', 'participants', 'artifacts', `return (${expression});`) + return fn(env.context, env.participants, env.artifacts) + } catch { + return undefined + } + } + + public static envFromInstance(instance: WorkflowInstanceData): GuardEnv { + return { + context: instance.context || {}, + participants: instance.participants || {}, + artifacts: instance.artifacts || {}, + } + } +} From 216bcdac8aa8755c687418a19a4d870953126f51 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:18 -0400 Subject: [PATCH 05/20] feat(workflow): add repositories for templates and instances Introduce records and repositories for workflow templates and instances. Signed-off-by: Vinay Singh --- .../src/repository/WorkflowInstanceRecord.ts | 91 +++++++++++++++++++ .../repository/WorkflowInstanceRepository.ts | 47 ++++++++++ .../src/repository/WorkflowTemplateRecord.ts | 48 ++++++++++ .../repository/WorkflowTemplateRepository.ts | 20 ++++ 4 files changed, 206 insertions(+) create mode 100644 packages/workflow/src/repository/WorkflowInstanceRecord.ts create mode 100644 packages/workflow/src/repository/WorkflowInstanceRepository.ts create mode 100644 packages/workflow/src/repository/WorkflowTemplateRecord.ts create mode 100644 packages/workflow/src/repository/WorkflowTemplateRepository.ts diff --git a/packages/workflow/src/repository/WorkflowInstanceRecord.ts b/packages/workflow/src/repository/WorkflowInstanceRecord.ts new file mode 100644 index 0000000000..ad729c9797 --- /dev/null +++ b/packages/workflow/src/repository/WorkflowInstanceRecord.ts @@ -0,0 +1,91 @@ +import type { TagsBase } from '@credo-ts/core' +import { BaseRecord, utils } from '@credo-ts/core' +import type { InstanceHistoryItem, Participants } from '../model/types' + +export type WorkflowInstanceStatus = 'active' | 'paused' | 'canceled' | 'completed' | 'error' + +export interface WorkflowInstanceRecordProps { + id?: string + createdAt?: Date + instanceId: string + templateId: string + templateVersion: string + connectionId?: string + participants: Participants + state: string + section?: string + context: Record + artifacts: Record + status: WorkflowInstanceStatus + history: InstanceHistoryItem[] + multiplicityKeyValue?: string + idempotencyKeys?: string[] + tags?: TagsBase +} + +export type DefaultWorkflowInstanceTags = { + instanceId: string + templateId: string + templateVersion: string + connectionId?: string + state: string + multiplicityKeyValue?: string +} + +export class WorkflowInstanceRecord + extends BaseRecord + implements WorkflowInstanceRecordProps +{ + public instanceId!: string + public templateId!: string + public templateVersion!: string + public connectionId?: string + public participants!: Participants + public state!: string + public section?: string + public context!: Record + public artifacts!: Record + public status!: WorkflowInstanceStatus + public history!: InstanceHistoryItem[] + public multiplicityKeyValue?: string + public idempotencyKeys?: string[] + public idempotency?: Array<{ key: string; event: string; to: string; actionKey?: string }> + + public static readonly type = 'WorkflowInstanceRecord' + public readonly type = WorkflowInstanceRecord.type + + public constructor(props: WorkflowInstanceRecordProps) { + super() + if (props) { + this.id = props.id ?? props.instanceId ?? utils.uuid() + this.createdAt = props.createdAt ?? new Date() + this.instanceId = props.instanceId + this.templateId = props.templateId + this.templateVersion = props.templateVersion + this.connectionId = props.connectionId + this.participants = props.participants + this.state = props.state + this.section = props.section + this.context = props.context ?? {} + this.artifacts = props.artifacts ?? {} + this.status = props.status + this.history = props.history ?? [] + this.multiplicityKeyValue = props.multiplicityKeyValue + this.idempotencyKeys = props.idempotencyKeys ?? [] + this.idempotency = (props as any).idempotency ?? [] + this._tags = props.tags ?? {} + } + } + + public getTags(): DefaultWorkflowInstanceTags { + return { + ...this._tags, + instanceId: this.instanceId, + templateId: this.templateId, + templateVersion: this.templateVersion, + connectionId: this.connectionId, + state: this.state, + multiplicityKeyValue: this.multiplicityKeyValue, + } + } +} diff --git a/packages/workflow/src/repository/WorkflowInstanceRepository.ts b/packages/workflow/src/repository/WorkflowInstanceRepository.ts new file mode 100644 index 0000000000..b662a57e7a --- /dev/null +++ b/packages/workflow/src/repository/WorkflowInstanceRepository.ts @@ -0,0 +1,47 @@ +import { EventEmitter, InjectionSymbols, Repository, StorageService, inject, injectable } from '@credo-ts/core' +import { WorkflowInstanceRecord } from './WorkflowInstanceRecord' + +@injectable() +export class WorkflowInstanceRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(WorkflowInstanceRecord, storageService, eventEmitter) + } + + public async findByTemplateAndConnection(agentContext: any, templateId: string, connectionId?: string) { + return this.findByQuery(agentContext, { templateId, ...(connectionId ? { connectionId } : {}) }) + } + + public async findByTemplateConnAndMultiplicity( + agentContext: any, + templateId: string, + connectionId: string | undefined, + multiplicityKeyValue: string + ) { + return this.findByQuery(agentContext, { + templateId, + ...(connectionId ? { connectionId } : {}), + multiplicityKeyValue, + }) + } + + public async findByConnection(agentContext: any, connectionId: string) { + return this.findByQuery(agentContext, { connectionId }) + } + + public async findLatestByConnection(agentContext: any, connectionId: string) { + const list = await this.findByConnection(agentContext, connectionId) + if (!list?.length) return null + return list.sort( + (a, b) => + (b.updatedAt?.getTime?.() || b.createdAt?.getTime?.() || 0) - + (a.updatedAt?.getTime?.() || a.createdAt?.getTime?.() || 0) + )[0] + } + + public async getByInstanceId(agentContext: any, instanceId: string) { + return this.findSingleByQuery(agentContext, { instanceId }) + } +} diff --git a/packages/workflow/src/repository/WorkflowTemplateRecord.ts b/packages/workflow/src/repository/WorkflowTemplateRecord.ts new file mode 100644 index 0000000000..8b9526de2f --- /dev/null +++ b/packages/workflow/src/repository/WorkflowTemplateRecord.ts @@ -0,0 +1,48 @@ +import type { TagsBase } from '@credo-ts/core' +import { BaseRecord, utils } from '@credo-ts/core' +import type { WorkflowTemplate } from '../model/types' + +export interface WorkflowTemplateRecordProps { + id?: string + createdAt?: Date + template: WorkflowTemplate + hash?: string + tags?: TagsBase +} + +export type DefaultWorkflowTemplateTags = { + templateId: string + version: string + hash?: string +} + +export class WorkflowTemplateRecord + extends BaseRecord + implements WorkflowTemplateRecordProps +{ + public template!: WorkflowTemplate + public hash?: string + + public static readonly type = 'WorkflowTemplateRecord' + public readonly type = WorkflowTemplateRecord.type + + public constructor(props: WorkflowTemplateRecordProps) { + super() + if (props) { + this.id = props.id ?? utils.uuid() + this.createdAt = props.createdAt ?? new Date() + this.template = props.template + this.hash = props.hash + this._tags = props.tags ?? {} + } + } + + public getTags(): DefaultWorkflowTemplateTags { + return { + ...this._tags, + templateId: this.template?.template_id, + version: this.template?.version, + hash: this.hash, + } + } +} diff --git a/packages/workflow/src/repository/WorkflowTemplateRepository.ts b/packages/workflow/src/repository/WorkflowTemplateRepository.ts new file mode 100644 index 0000000000..827e936509 --- /dev/null +++ b/packages/workflow/src/repository/WorkflowTemplateRepository.ts @@ -0,0 +1,20 @@ +import { EventEmitter, InjectionSymbols, Repository, StorageService, inject, injectable } from '@credo-ts/core' +import { WorkflowTemplateRecord } from './WorkflowTemplateRecord' + +@injectable() +export class WorkflowTemplateRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(WorkflowTemplateRecord, storageService, eventEmitter) + } + + public async findByTemplateIdAndVersion(agentContext: any, templateId: string, version?: string) { + const list = await this.findByQuery(agentContext, { templateId, ...(version ? { version } : {}) }) + if (!list?.length) return null + if (version) return list[0] + // choose highest semver-like lexicographically if multiple + return list.sort((a, b) => (b.template.version || '').localeCompare(a.template.version || ''))[0] + } +} From 12644340d2c11d6f9e4adba38f939e91ff92da01 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:29 -0400 Subject: [PATCH 06/20] feat(workflow): add protocol message types and constructors Add WorkflowMessageTypes and protocol message classes: PublishTemplate, Start, Advance, StatusRequest/Status, ProblemReport, Cancel, Pause, Resume, Complete. Signed-off-by: Vinay Singh --- .../src/protocol/WorkflowMessageTypes.ts | 11 ++++++++ .../src/protocol/messages/AdvanceMessage.ts | 20 +++++++++++++ .../src/protocol/messages/CancelMessage.ts | 20 +++++++++++++ .../src/protocol/messages/CompleteMessage.ts | 20 +++++++++++++ .../src/protocol/messages/PauseMessage.ts | 20 +++++++++++++ .../protocol/messages/ProblemReportMessage.ts | 20 +++++++++++++ .../messages/PublishTemplateMessage.ts | 20 +++++++++++++ .../src/protocol/messages/ResumeMessage.ts | 20 +++++++++++++ .../src/protocol/messages/StartMessage.ts | 28 +++++++++++++++++++ .../src/protocol/messages/StatusMessage.ts | 28 +++++++++++++++++++ .../protocol/messages/StatusRequestMessage.ts | 20 +++++++++++++ 11 files changed, 227 insertions(+) create mode 100644 packages/workflow/src/protocol/WorkflowMessageTypes.ts create mode 100644 packages/workflow/src/protocol/messages/AdvanceMessage.ts create mode 100644 packages/workflow/src/protocol/messages/CancelMessage.ts create mode 100644 packages/workflow/src/protocol/messages/CompleteMessage.ts create mode 100644 packages/workflow/src/protocol/messages/PauseMessage.ts create mode 100644 packages/workflow/src/protocol/messages/ProblemReportMessage.ts create mode 100644 packages/workflow/src/protocol/messages/PublishTemplateMessage.ts create mode 100644 packages/workflow/src/protocol/messages/ResumeMessage.ts create mode 100644 packages/workflow/src/protocol/messages/StartMessage.ts create mode 100644 packages/workflow/src/protocol/messages/StatusMessage.ts create mode 100644 packages/workflow/src/protocol/messages/StatusRequestMessage.ts diff --git a/packages/workflow/src/protocol/WorkflowMessageTypes.ts b/packages/workflow/src/protocol/WorkflowMessageTypes.ts new file mode 100644 index 0000000000..fe2a951790 --- /dev/null +++ b/packages/workflow/src/protocol/WorkflowMessageTypes.ts @@ -0,0 +1,11 @@ +export const WorkflowMessageType = { + PublishTemplate: 'https://didcomm.org/workflow/1.0/publish-template', + Start: 'https://didcomm.org/workflow/1.0/start', + Advance: 'https://didcomm.org/workflow/1.0/advance', + Status: 'https://didcomm.org/workflow/1.0/status', + ProblemReport: 'https://didcomm.org/workflow/1.0/problem-report', + Pause: 'https://didcomm.org/workflow/1.0/pause', + Resume: 'https://didcomm.org/workflow/1.0/resume', + Cancel: 'https://didcomm.org/workflow/1.0/cancel', + Complete: 'https://didcomm.org/workflow/1.0/complete', +} as const diff --git a/packages/workflow/src/protocol/messages/AdvanceMessage.ts b/packages/workflow/src/protocol/messages/AdvanceMessage.ts new file mode 100644 index 0000000000..d3781934c2 --- /dev/null +++ b/packages/workflow/src/protocol/messages/AdvanceMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class AdvanceMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/advance') + + @IsValidMessageType(AdvanceMessage.type) + public type = AdvanceMessage.type.messageTypeUri + + public body!: { instance_id: string; event: string; idempotency_key?: string } + + public constructor(options?: { id?: string; body: AdvanceMessage['body']; thid?: string }) { + super() + this.type = AdvanceMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/CancelMessage.ts b/packages/workflow/src/protocol/messages/CancelMessage.ts new file mode 100644 index 0000000000..6ac466009f --- /dev/null +++ b/packages/workflow/src/protocol/messages/CancelMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class CancelMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/cancel') + + @IsValidMessageType(CancelMessage.type) + public type = CancelMessage.type.messageTypeUri + + public body!: { instance_id: string; reason?: string } + + public constructor(options?: { id?: string; body: CancelMessage['body']; thid?: string }) { + super() + this.type = CancelMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/CompleteMessage.ts b/packages/workflow/src/protocol/messages/CompleteMessage.ts new file mode 100644 index 0000000000..a7d1410587 --- /dev/null +++ b/packages/workflow/src/protocol/messages/CompleteMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class CompleteMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/complete') + + @IsValidMessageType(CompleteMessage.type) + public type = CompleteMessage.type.messageTypeUri + + public body!: { instance_id: string; reason?: string } + + public constructor(options?: { id?: string; body: CompleteMessage['body']; thid?: string }) { + super() + this.type = CompleteMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/PauseMessage.ts b/packages/workflow/src/protocol/messages/PauseMessage.ts new file mode 100644 index 0000000000..39d2d0674c --- /dev/null +++ b/packages/workflow/src/protocol/messages/PauseMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class PauseMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/pause') + + @IsValidMessageType(PauseMessage.type) + public type = PauseMessage.type.messageTypeUri + + public body!: { instance_id: string; reason?: string } + + public constructor(options?: { id?: string; body: PauseMessage['body']; thid?: string }) { + super() + this.type = PauseMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/ProblemReportMessage.ts b/packages/workflow/src/protocol/messages/ProblemReportMessage.ts new file mode 100644 index 0000000000..85c328ff7b --- /dev/null +++ b/packages/workflow/src/protocol/messages/ProblemReportMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class ProblemReportMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/problem-report') + + @IsValidMessageType(ProblemReportMessage.type) + public type = ProblemReportMessage.type.messageTypeUri + + public body!: { code: string; comment?: string; args?: any } + + public constructor(options?: { id?: string; body: ProblemReportMessage['body']; thid?: string }) { + super() + this.type = ProblemReportMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/PublishTemplateMessage.ts b/packages/workflow/src/protocol/messages/PublishTemplateMessage.ts new file mode 100644 index 0000000000..a5b0b904df --- /dev/null +++ b/packages/workflow/src/protocol/messages/PublishTemplateMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' +import type { WorkflowTemplate } from '../../model/types' + +export class PublishTemplateMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/publish-template') + + @IsValidMessageType(PublishTemplateMessage.type) + public type = PublishTemplateMessage.type.messageTypeUri + + public body!: { template: WorkflowTemplate; mode?: 'upsert' } + + public constructor(options?: { id?: string; body: { template: WorkflowTemplate; mode?: 'upsert' } }) { + super() + this.type = PublishTemplateMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + } + } +} diff --git a/packages/workflow/src/protocol/messages/ResumeMessage.ts b/packages/workflow/src/protocol/messages/ResumeMessage.ts new file mode 100644 index 0000000000..16f7c16d7a --- /dev/null +++ b/packages/workflow/src/protocol/messages/ResumeMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class ResumeMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/resume') + + @IsValidMessageType(ResumeMessage.type) + public type = ResumeMessage.type.messageTypeUri + + public body!: { instance_id: string; reason?: string } + + public constructor(options?: { id?: string; body: ResumeMessage['body']; thid?: string }) { + super() + this.type = ResumeMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/StartMessage.ts b/packages/workflow/src/protocol/messages/StartMessage.ts new file mode 100644 index 0000000000..1371bddb39 --- /dev/null +++ b/packages/workflow/src/protocol/messages/StartMessage.ts @@ -0,0 +1,28 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' +import type { Participants } from '../../model/types' + +export class StartMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/start') + + @IsValidMessageType(StartMessage.type) + public type = StartMessage.type.messageTypeUri + + public body!: { + template_id: string + template_version?: string + instance_id?: string + connection_id?: string + participants?: Participants + context?: Record + } + + public constructor(options?: { id?: string; body: StartMessage['body']; thid?: string }) { + super() + this.type = StartMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/StatusMessage.ts b/packages/workflow/src/protocol/messages/StatusMessage.ts new file mode 100644 index 0000000000..97f4014ebe --- /dev/null +++ b/packages/workflow/src/protocol/messages/StatusMessage.ts @@ -0,0 +1,28 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class StatusMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/status') + + @IsValidMessageType(StatusMessage.type) + public type = StatusMessage.type.messageTypeUri + + public body!: { + instance_id: string + state: string + section?: string + allowed_events: string[] + action_menu: Array<{ label?: string; event: string }> + artifacts: Record + ui?: any[] + } + + public constructor(options?: { id?: string; body: StatusMessage['body']; thid?: string }) { + super() + this.type = StatusMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} diff --git a/packages/workflow/src/protocol/messages/StatusRequestMessage.ts b/packages/workflow/src/protocol/messages/StatusRequestMessage.ts new file mode 100644 index 0000000000..b963398566 --- /dev/null +++ b/packages/workflow/src/protocol/messages/StatusRequestMessage.ts @@ -0,0 +1,20 @@ +import { DidCommMessage, IsValidMessageType, parseMessageType } from '@credo-ts/didcomm' + +export class StatusRequestMessage extends DidCommMessage { + public static readonly type = parseMessageType('https://didcomm.org/workflow/1.0/status') + + @IsValidMessageType(StatusRequestMessage.type) + public type = StatusRequestMessage.type.messageTypeUri + + public body!: { instance_id: string; include_actions?: boolean; include_ui?: boolean } + + public constructor(options?: { id?: string; body: StatusRequestMessage['body']; thid?: string }) { + super() + this.type = StatusRequestMessage.type.messageTypeUri + if (options) { + this.id = options.id || this.generateId() + this.body = options.body + if (options.thid) this.setThread({ threadId: options.thid }) + } + } +} From 423a2352883e27f3494d00b7bcb70e0f25befa79 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:33 -0400 Subject: [PATCH 07/20] feat(workflow): add protocol handlers (publish, start, status, etc.) Implement handlers for publish, start, advance, status, pause/resume, complete, cancel, and problem-report. Signed-off-by: Vinay Singh --- .../src/protocol/handlers/AdvanceHandler.ts | 54 ++++++++++++++++ .../src/protocol/handlers/CancelHandler.ts | 51 ++++++++++++++++ .../src/protocol/handlers/CompleteHandler.ts | 48 +++++++++++++++ .../src/protocol/handlers/PauseHandler.ts | 51 ++++++++++++++++ .../protocol/handlers/ProblemReportHandler.ts | 15 +++++ .../handlers/PublishTemplateHandler.ts | 35 +++++++++++ .../src/protocol/handlers/ResumeHandler.ts | 51 ++++++++++++++++ .../src/protocol/handlers/StartHandler.ts | 57 +++++++++++++++++ .../src/protocol/handlers/StatusHandler.ts | 61 +++++++++++++++++++ 9 files changed, 423 insertions(+) create mode 100644 packages/workflow/src/protocol/handlers/AdvanceHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/CancelHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/CompleteHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/PauseHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/ProblemReportHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/ResumeHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/StartHandler.ts create mode 100644 packages/workflow/src/protocol/handlers/StatusHandler.ts diff --git a/packages/workflow/src/protocol/handlers/AdvanceHandler.ts b/packages/workflow/src/protocol/handlers/AdvanceHandler.ts new file mode 100644 index 0000000000..bfa7184b9a --- /dev/null +++ b/packages/workflow/src/protocol/handlers/AdvanceHandler.ts @@ -0,0 +1,54 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { AdvanceMessage } from '../messages/AdvanceMessage' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class AdvanceHandler implements DidCommMessageHandler { + public supportedMessages = [AdvanceMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const thid = messageContext.message.threadId || messageContext.message.id + const { instance_id, event } = messageContext.message.body || {} + logger.info('[Workflow] advance received', { instance_id, event, thid }) + try { + const _th = messageContext.message.threadId + const _id = messageContext.message.body?.instance_id + if (_id && _th && _id !== _th) { + logger.warn('[Workflow] threadId does not match instance_id', { thid: _th, instance_id: _id }) + } + } catch {} + try { + await this.service.advance(messageContext.agentContext, messageContext.message.body) + const instId = messageContext.message.body.instance_id + const status = await this.service.status(messageContext.agentContext, { instance_id: instId }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/CancelHandler.ts b/packages/workflow/src/protocol/handlers/CancelHandler.ts new file mode 100644 index 0000000000..a7b272eb56 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/CancelHandler.ts @@ -0,0 +1,51 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { CancelMessage } from '../messages/CancelMessage' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class CancelHandler implements DidCommMessageHandler { + public supportedMessages = [CancelMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const instId = messageContext.message.body?.instance_id + const thid = messageContext.message.threadId || messageContext.message.id + logger.info('[Workflow] cancel received', { instance_id: instId, thid }) + try { + await this.service.cancel(messageContext.agentContext, messageContext.message.body) + const instId = messageContext.message.body.instance_id + const status = await this.service.status(messageContext.agentContext, { instance_id: instId }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if ((e as any)?.code === 'invalid_event') { + logger.info('[Workflow] cancel ignored (no local instance)', { instance_id: instId }) + return undefined + } + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/CompleteHandler.ts b/packages/workflow/src/protocol/handlers/CompleteHandler.ts new file mode 100644 index 0000000000..4aee7915eb --- /dev/null +++ b/packages/workflow/src/protocol/handlers/CompleteHandler.ts @@ -0,0 +1,48 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { CompleteMessage } from '../messages/CompleteMessage' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class CompleteHandler implements DidCommMessageHandler { + public supportedMessages = [CompleteMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const instId = messageContext.message.body?.instance_id + const thid = messageContext.message.threadId || messageContext.message.id + logger.info('[Workflow] complete received', { instance_id: instId, thid }) + try { + await this.service.complete(messageContext.agentContext, messageContext.message.body) + const status = await this.service.status(messageContext.agentContext, { instance_id: instId }) + const reply = new StatusMessage({ thid, body: status }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + // If the receiving agent doesn't host the instance, ignore silently (no problem-report) + if ((e as any)?.code === 'invalid_event') { + logger.info('[Workflow] complete ignored (no local instance)', { instance_id: instId }) + return undefined + } + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/PauseHandler.ts b/packages/workflow/src/protocol/handlers/PauseHandler.ts new file mode 100644 index 0000000000..85b88ce2e1 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/PauseHandler.ts @@ -0,0 +1,51 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { PauseMessage } from '../messages/PauseMessage' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class PauseHandler implements DidCommMessageHandler { + public supportedMessages = [PauseMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const instId = messageContext.message.body?.instance_id + const thid = messageContext.message.threadId || messageContext.message.id + logger.info('[Workflow] pause received', { instance_id: instId, thid }) + try { + await this.service.pause(messageContext.agentContext, messageContext.message.body) + const instId = messageContext.message.body.instance_id + const status = await this.service.status(messageContext.agentContext, { instance_id: instId }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if ((e as any)?.code === 'invalid_event') { + logger.info('[Workflow] pause ignored (no local instance)', { instance_id: instId }) + return undefined + } + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/ProblemReportHandler.ts b/packages/workflow/src/protocol/handlers/ProblemReportHandler.ts new file mode 100644 index 0000000000..1043966472 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/ProblemReportHandler.ts @@ -0,0 +1,15 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' + +@injectable() +export class ProblemReportHandler implements DidCommMessageHandler { + public supportedMessages = [ProblemReportMessage] + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const logger = messageContext.agentContext.dependencyManager.resolve(AgentConfig).logger + const body = messageContext.message.body + const thid = messageContext.message.threadId || messageContext.message.id + logger.warn('[Workflow] problem-report received', { thid, code: body?.code, comment: body?.comment }) + return undefined + } +} diff --git a/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts b/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts new file mode 100644 index 0000000000..ac42b58c16 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts @@ -0,0 +1,35 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { PublishTemplateMessage } from '../messages/PublishTemplateMessage' + +@injectable() +export class PublishTemplateHandler implements DidCommMessageHandler { + public supportedMessages = [PublishTemplateMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + logger.info('[Workflow] publish-template received') + try { + await this.service.publishTemplate(messageContext.agentContext, messageContext.message.body.template) + return + } catch (e) { + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/ResumeHandler.ts b/packages/workflow/src/protocol/handlers/ResumeHandler.ts new file mode 100644 index 0000000000..400d4b97f4 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/ResumeHandler.ts @@ -0,0 +1,51 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { ResumeMessage } from '../messages/ResumeMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class ResumeHandler implements DidCommMessageHandler { + public supportedMessages = [ResumeMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const instId = messageContext.message.body?.instance_id + const thid = messageContext.message.threadId || messageContext.message.id + logger.info('[Workflow] resume received', { instance_id: instId, thid }) + try { + await this.service.resume(messageContext.agentContext, messageContext.message.body) + const instId = messageContext.message.body.instance_id + const status = await this.service.status(messageContext.agentContext, { instance_id: instId }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if ((e as any)?.code === 'invalid_event') { + logger.info('[Workflow] resume ignored (no local instance)', { instance_id: instId }) + return undefined + } + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/StartHandler.ts b/packages/workflow/src/protocol/handlers/StartHandler.ts new file mode 100644 index 0000000000..3526ce6b04 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/StartHandler.ts @@ -0,0 +1,57 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StartMessage } from '../messages/StartMessage' +import { StatusMessage } from '../messages/StatusMessage' + +@injectable() +export class StartHandler implements DidCommMessageHandler { + public supportedMessages = [StartMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const thid = messageContext.message.threadId || messageContext.message.id + const iid = messageContext.message.body?.instance_id + logger.info('[Workflow] start received', { + template_id: messageContext.message.body?.template_id, + instance_id: iid, + thid, + }) + try { + const _th = messageContext.message.threadId + const _id = messageContext.message.body?.instance_id + if (_id && _th && _id !== _th) { + logger.warn('[Workflow] threadId does not match instance_id', { thid: _th, instance_id: _id }) + } + } catch {} + try { + const rec = await this.service.start(messageContext.agentContext, messageContext.message.body) + const status = await this.service.status(messageContext.agentContext, { instance_id: rec.instanceId }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} diff --git a/packages/workflow/src/protocol/handlers/StatusHandler.ts b/packages/workflow/src/protocol/handlers/StatusHandler.ts new file mode 100644 index 0000000000..d9952012d9 --- /dev/null +++ b/packages/workflow/src/protocol/handlers/StatusHandler.ts @@ -0,0 +1,61 @@ +import { AgentConfig, injectable } from '@credo-ts/core' +import type { DidCommMessageHandler, DidCommMessageHandlerInboundMessage } from '@credo-ts/didcomm' +import { DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { WorkflowModuleConfig } from '../../WorkflowModuleConfig' +import { WorkflowService } from '../../services/WorkflowService' +import { ProblemReportMessage } from '../messages/ProblemReportMessage' +import { StatusMessage } from '../messages/StatusMessage' +import { StatusRequestMessage } from '../messages/StatusRequestMessage' + +@injectable() +export class StatusHandler implements DidCommMessageHandler { + public supportedMessages = [StatusRequestMessage] + public constructor(private readonly service: WorkflowService) {} + public async handle(messageContext: DidCommMessageHandlerInboundMessage) { + const dm = messageContext.agentContext.dependencyManager + const logger = dm.resolve(AgentConfig).logger + const config = dm.resolve(WorkflowModuleConfig) + const thid = messageContext.message.threadId || messageContext.message.id + const iid = messageContext.message.body?.instance_id + logger.info('[Workflow] status request received', { + instance_id: iid, + thid, + include_actions: messageContext.message.body?.include_actions, + include_ui: messageContext.message.body?.include_ui, + }) + try { + const _th = messageContext.message.threadId + const _id = messageContext.message.body?.instance_id + if (_id && _th && _id !== _th) { + logger.warn('[Workflow] threadId does not match instance_id', { thid: _th, instance_id: _id }) + } + } catch {} + try { + const status = await this.service.status(messageContext.agentContext, { + instance_id: messageContext.message.body.instance_id, + include_actions: messageContext.message.body.include_actions, + include_ui: messageContext.message.body.include_ui, + }) + const reply = new StatusMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: status, + }) + return new DidCommOutboundMessageContext(reply, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } catch (e) { + if (config.enableProblemReport && messageContext.connection) { + const pr = new ProblemReportMessage({ + thid: messageContext.message.threadId || messageContext.message.id, + body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + }) + return new DidCommOutboundMessageContext(pr, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + }) + } + throw e + } + } +} From d4a2b48b9c898106dc0a466144f8ccbcdc01427f Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:40 -0400 Subject: [PATCH 08/20] feat(workflow): add actions, service and API Add ActionRegistry, WorkflowService, and WorkflowApi to orchestrate and expose workflow operations. Signed-off-by: Vinay Singh --- .../workflow/src/actions/ActionRegistry.ts | 177 ++++++ packages/workflow/src/api/WorkflowApi.ts | 65 +++ .../workflow/src/services/WorkflowService.ts | 542 ++++++++++++++++++ 3 files changed, 784 insertions(+) create mode 100644 packages/workflow/src/actions/ActionRegistry.ts create mode 100644 packages/workflow/src/api/WorkflowApi.ts create mode 100644 packages/workflow/src/services/WorkflowService.ts diff --git a/packages/workflow/src/actions/ActionRegistry.ts b/packages/workflow/src/actions/ActionRegistry.ts new file mode 100644 index 0000000000..6c760ac2e4 --- /dev/null +++ b/packages/workflow/src/actions/ActionRegistry.ts @@ -0,0 +1,177 @@ +import type { AgentContext } from '@credo-ts/core' +import { AttributePlanner, deepMerge } from '../engine/AttributePlanner' +import type { ActionDef, WorkflowInstanceData, WorkflowTemplate } from '../model/types' + +export type ActionCtx = { + agentContext: AgentContext + template: WorkflowTemplate + instance: WorkflowInstanceData + action: ActionDef + input?: any +} + +export type ActionResult = { + artifacts?: Record + contextMerge?: Record + messageId?: string +} + +export interface WorkflowActionHandler { + readonly typeUri: string + execute(ctx: ActionCtx): Promise +} + +export class ActionRegistry { + private handlers = new Map() + public register(handler: WorkflowActionHandler) { + this.handlers.set(handler.typeUri, handler) + } + public get(typeUri: string): WorkflowActionHandler | undefined { + return this.handlers.get(typeUri) + } +} + +export class LocalStateSetAction implements WorkflowActionHandler { + public readonly typeUri = 'https://didcomm.org/workflow/actions/state:set@1' + public async execute(ctx: ActionCtx): Promise { + const anyAct: any = ctx.action + const mergeObj = anyAct?.staticInput?.merge + if (mergeObj && typeof mergeObj === 'object') { + const next = deepMerge({ ...(ctx.instance.context || {}) }, mergeObj) + return { contextMerge: next } + } + // if string with template, try basic input resolution: '{{ input.form }}' + if (typeof mergeObj === 'string' && ctx.input && mergeObj.includes('input.')) { + try { + const path = mergeObj + .replace(/\{\{|\}\}/g, '') + .trim() + .replace(/^input\./, '') + const value = path.split('.').reduce((acc: any, p: string) => (acc == null ? undefined : acc[p]), ctx.input) + if (value && typeof value === 'object') { + const next = deepMerge({ ...(ctx.instance.context || {}) }, value) + return { contextMerge: next } + } + } catch {} + } + return {} + } +} + +export class IssueCredentialV2Action implements WorkflowActionHandler { + public readonly typeUri = 'https://didcomm.org/issue-credential/2.0/offer-credential' + public async execute(ctx: ActionCtx): Promise { + const act: any = ctx.action + const ref: string = act.profile_ref + if (!ref?.startsWith('cp.')) throw Object.assign(new Error('invalid profile_ref'), { code: 'action_error' }) + const key = ref.slice(3) + const profile = ctx.template.catalog?.credential_profiles?.[key] + if (!profile) throw Object.assign(new Error('missing catalog profile'), { code: 'action_error' }) + const attrs = AttributePlanner.materialize(profile.attribute_plan || {}, ctx.instance) + const attributes = Object.entries(attrs).map(([name, value]) => ({ name, value: String(value) })) + const connectionId = ctx.instance.connection_id + if (!connectionId) throw Object.assign(new Error('connectionId required'), { code: 'action_error' }) + // Enforce to_ref recipient DID against connection counterparty DID (if available) + { + const toRef = profile.to_ref + const expectedDid = toRef ? ctx.instance.participants?.[toRef]?.did : undefined + if (expectedDid && ctx.instance.connection_id) { + const { + DidCommConnectionService, + } = require('@credo-ts/didcomm/src/modules/connections/services/DidCommConnectionService') + const connSvc: any = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) + const conn = await connSvc.getById(ctx.agentContext, ctx.instance.connection_id) + const theirDid = (conn as any)?.theirDid + if (theirDid && theirDid !== expectedDid) + throw Object.assign(new Error('to_ref DID mismatch'), { code: 'forbidden' }) + } + } + try { + const { DidCommCredentialsApi } = require('@credo-ts/didcomm/src/modules/credentials/DidCommCredentialsApi') + const credsApi: any = ctx.agentContext.dependencyManager.resolve(DidCommCredentialsApi) + const record = await credsApi.offerCredential({ + connectionId, + protocolVersion: 'v2', + credentialFormats: { anoncreds: { credentialDefinitionId: profile.cred_def_id, attributes } }, + comment: profile.options?.comment, + } as any) + let messageId = record?.id || record?.credentialRecord?.id + try { + const found = await credsApi.findOfferMessage(messageId) + messageId = found?.message?.id || messageId + } catch {} + return { artifacts: { issueRecordId: record?.id || record?.credentialRecord?.id }, messageId } + } catch (e) { + throw Object.assign(new Error(`issue action error: ${(e as Error).message}`), { code: 'action_error' }) + } + } +} + +export class PresentProofV2Action implements WorkflowActionHandler { + public readonly typeUri = 'https://didcomm.org/present-proof/2.0/request-presentation' + public async execute(ctx: ActionCtx): Promise { + const act: any = ctx.action + const ref: string = act.profile_ref + if (!ref?.startsWith('pp.')) throw Object.assign(new Error('invalid profile_ref'), { code: 'action_error' }) + const key = ref.slice(3) + const profile = ctx.template.catalog?.proof_profiles?.[key] + if (!profile) throw Object.assign(new Error('missing catalog profile'), { code: 'action_error' }) + const connectionId = ctx.instance.connection_id + if (!connectionId) throw Object.assign(new Error('connectionId required'), { code: 'action_error' }) + // Enforce to_ref recipient DID against connection counterparty DID (if available) + { + const toRef2 = profile.to_ref + const expectedDid2 = toRef2 ? ctx.instance.participants?.[toRef2]?.did : undefined + if (expectedDid2 && ctx.instance.connection_id) { + const { + DidCommConnectionService, + } = require('@credo-ts/didcomm/src/modules/connections/services/DidCommConnectionService') + const connSvc: any = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) + const conn = await connSvc.getById(ctx.agentContext, ctx.instance.connection_id) + const theirDid = (conn as any)?.theirDid + if (theirDid && theirDid !== expectedDid2) + throw Object.assign(new Error('to_ref DID mismatch'), { code: 'forbidden' }) + } + } + try { + const { DidCommProofsApi } = require('@credo-ts/didcomm/src/modules/proofs/DidCommProofsApi') + const proofsApi: any = ctx.agentContext.dependencyManager.resolve(DidCommProofsApi) + const credDefId = (profile as any).cred_def_id + const schemaId = (profile as any).schema_id + const restriction = credDefId ? { cred_def_id: credDefId } : schemaId ? { schema_id: schemaId } : undefined + + const reqAttrs = (profile.requested_attributes || []).reduce((acc: any, name: string, idx: number) => { + acc[`attr${idx + 1}`] = restriction ? { name, restrictions: [restriction] } : { name } + return acc + }, {}) + const reqPreds = (profile.requested_predicates || []).reduce((acc: any, p: any, idx: number) => { + acc[`pred${idx + 1}`] = restriction + ? { name: p.name, p_type: p.p_type, p_value: p.p_value, restrictions: [restriction] } + : { name: p.name, p_type: p.p_type, p_value: p.p_value } + return acc + }, {}) + const record = await proofsApi.requestProof({ + connectionId, + protocolVersion: 'v2', + proofFormats: { + anoncreds: { + name: 'Workflow Proof Request', + version: '1.0', + requested_attributes: reqAttrs, + requested_predicates: reqPreds, + }, + }, + willConfirm: true, + comment: profile.options?.comment, + } as any) + let messageId = record?.id || record?.proofRecord?.id + try { + const found = await proofsApi.findRequestMessage(messageId) + messageId = found?.message?.id || messageId + } catch {} + return { artifacts: { proofRecordId: record?.id || record?.proofRecord?.id }, messageId } + } catch (e) { + throw Object.assign(new Error(`proof action error: ${(e as Error).message}`), { code: 'action_error' }) + } + } +} diff --git a/packages/workflow/src/api/WorkflowApi.ts b/packages/workflow/src/api/WorkflowApi.ts new file mode 100644 index 0000000000..aa1b06a27f --- /dev/null +++ b/packages/workflow/src/api/WorkflowApi.ts @@ -0,0 +1,65 @@ +import { AgentContext, injectable } from '@credo-ts/core' +import type { Participants, WorkflowTemplate } from '../model/types' +import { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' +import { WorkflowTemplateRecord } from '../repository/WorkflowTemplateRecord' +import { WorkflowService } from '../services/WorkflowService' + +@injectable() +export class WorkflowApi { + public constructor( + private readonly service: WorkflowService, + private readonly agentContext: AgentContext + ) {} + + public publishTemplate(template: WorkflowTemplate): Promise { + return this.service.publishTemplate(this.agentContext, template) + } + + public start(opts: { + template_id: string + template_version?: string + instance_id?: string + connection_id?: string + participants?: Participants + context?: Record + }): Promise { + return this.service.start(this.agentContext, opts) + } + + public advance(opts: { + instance_id: string + event: string + idempotency_key?: string + input?: any + }): Promise { + return this.service.advance(this.agentContext, opts) + } + + public status(opts: { instance_id: string; include_actions?: boolean; include_ui?: boolean }): Promise<{ + instance_id: string + state: string + section?: string + allowed_events: string[] + action_menu: Array<{ label?: string; event: string }> + artifacts: Record + ui?: any[] + }> { + return this.service.status(this.agentContext, opts) + } + + public pause(opts: { instance_id: string; reason?: string }) { + return this.service.pause(this.agentContext, opts) + } + + public resume(opts: { instance_id: string; reason?: string }) { + return this.service.resume(this.agentContext, opts) + } + + public cancel(opts: { instance_id: string; reason?: string }) { + return this.service.cancel(this.agentContext, opts) + } + + public complete(opts: { instance_id: string; reason?: string }) { + return this.service.complete(this.agentContext, opts) + } +} diff --git a/packages/workflow/src/services/WorkflowService.ts b/packages/workflow/src/services/WorkflowService.ts new file mode 100644 index 0000000000..f103117154 --- /dev/null +++ b/packages/workflow/src/services/WorkflowService.ts @@ -0,0 +1,542 @@ +import { createHash } from 'crypto' +import { AgentConfig, AgentContext, EventEmitter, injectable } from '@credo-ts/core' +import { DidCommMessageSender, DidCommOutboundMessageContext } from '@credo-ts/didcomm' +import { DidCommConnectionService } from '@credo-ts/didcomm' +import { WorkflowEventTypes } from '../WorkflowEvents' +import { WorkflowModuleConfig } from '../WorkflowModuleConfig' +import { + ActionRegistry, + IssueCredentialV2Action, + LocalStateSetAction, + PresentProofV2Action, +} from '../actions/ActionRegistry' +import { GuardEvaluator } from '../engine/GuardEvaluator' +import { validateTemplateJson, validateTemplateRefs } from '../model/TemplateValidation' +import type { Participants, WorkflowInstanceData, WorkflowTemplate } from '../model/types' +import { ensureArray, findSectionForState, transitionsFromState } from '../model/types' +import { CompleteMessage } from '../protocol/messages/CompleteMessage' +import { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' +import { WorkflowInstanceRepository } from '../repository/WorkflowInstanceRepository' +import { WorkflowTemplateRecord } from '../repository/WorkflowTemplateRecord' +import { WorkflowTemplateRepository } from '../repository/WorkflowTemplateRepository' + +const stableStringify = (obj: any): string => { + const allKeys: string[] = [] + JSON.stringify(obj, (k, v) => (allKeys.push(k), v)) + allKeys.sort() + return JSON.stringify(obj, allKeys) +} + +const sha256 = (input: string): string => createHash('sha256').update(input).digest('hex') + +@injectable() +export class WorkflowService { + private readonly actions: ActionRegistry + + public constructor( + private readonly templateRepo: WorkflowTemplateRepository, + private readonly instanceRepo: WorkflowInstanceRepository, + private readonly config: WorkflowModuleConfig, + private readonly agentConfig: AgentConfig, + private readonly eventEmitter?: EventEmitter + ) { + this.actions = new ActionRegistry() + this.actions.register(new LocalStateSetAction()) + // Register DIDComm action handlers used by workflows + this.actions.register(new IssueCredentialV2Action()) + this.actions.register(new PresentProofV2Action()) + } + + public async publishTemplate( + agentContext: AgentContext, + template: WorkflowTemplate + ): Promise { + // JSON schema validation + structural checks + validateTemplateJson(template as any) + validateTemplateRefs(template) + const hash = sha256(stableStringify(template)) + const existing = await this.templateRepo.findByTemplateIdAndVersion( + agentContext, + template.template_id, + template.version + ) + if (existing) { + existing.template = template + existing.hash = hash + await this.templateRepo.update(agentContext, existing) + return existing + } + const record = new WorkflowTemplateRecord({ template, hash }) + await this.templateRepo.save(agentContext, record) + return record + } + + public async start( + agentContext: AgentContext, + opts: { + template_id: string + template_version?: string + instance_id?: string + connection_id?: string + participants?: Participants + context?: Record + } + ): Promise { + const tplRec = await this.templateRepo.findByTemplateIdAndVersion( + agentContext, + opts.template_id, + opts.template_version + ) + if (!tplRec) + throw this.problem( + 'invalid_template', + `template not found: ${opts.template_id}@${opts.template_version || 'latest'}` + ) + const tpl = tplRec.template + const startState = tpl.states.find((s) => s.type === 'start')?.name || tpl.states[0]?.name + if (!startState) throw this.problem('invalid_template', 'no start state') + + const policy = tpl.instance_policy + const connectionId = opts.connection_id + if (policy.mode === 'singleton_per_connection') { + const existing = ensureArray( + await this.instanceRepo.findByTemplateAndConnection(agentContext, tpl.template_id, connectionId) + ).shift() + if (existing) { + if (this.config.autoReturnExistingOnSingleton) return existing + throw this.problem('already_exists', 'instance already exists for template/connection') + } + } + + let multiplicityKeyValue: string | undefined + if (policy.mode === 'multi_per_connection' && policy.multiplicity_key) { + multiplicityKeyValue = this.evalMultiplicity(policy.multiplicity_key, opts.context || {}) + const dup = ensureArray( + await this.instanceRepo.findByTemplateConnAndMultiplicity( + agentContext, + tpl.template_id, + connectionId, + multiplicityKeyValue + ) + ).shift() + if (dup) return dup + } + + const instanceId = opts.instance_id || this.uuid() + const section = findSectionForState(tpl, startState) + const rec = new WorkflowInstanceRecord({ + instanceId, + templateId: tpl.template_id, + templateVersion: tpl.version, + connectionId, + participants: opts.participants || {}, + state: startState, + section, + context: { ...(opts.context || {}) }, + artifacts: {}, + status: 'active', + history: [], + multiplicityKeyValue, + idempotencyKeys: [], + }) + await this.instanceRepo.save(agentContext, rec) + // Emit state changed event for initial creation + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStateChanged, + payload: { + instanceRecord: rec, + previousState: null, + newState: rec.state, + event: 'start', + actionKey: undefined, + msgId: undefined, + }, + }) + } catch {} + return rec + } + + public async advance( + agentContext: AgentContext, + opts: { + instance_id: string + event: string + idempotency_key?: string + input?: any + } + ): Promise { + let inst: WorkflowInstanceRecord + try { + inst = await this.instanceRepo.getById(agentContext, opts.instance_id) + } catch { + const found = await this.instanceRepo.getByInstanceId(agentContext, opts.instance_id) + if (!found) throw this.problem('invalid_event', 'instance not found') + inst = found + } + const tplRec = await this.templateRepo.findByTemplateIdAndVersion( + agentContext, + inst.templateId, + inst.templateVersion + ) + if (!tplRec) throw this.problem('invalid_template', 'template not found for instance') + const tpl = tplRec.template + + // lifecycle status gating + if (inst.status === 'paused') throw this.problem('forbidden', 'instance is paused') + if (inst.status === 'canceled') throw this.problem('forbidden', 'instance is canceled') + if (inst.status === 'completed') throw this.problem('invalid_event', 'instance already completed') + + // idempotency + if (opts.idempotency_key && inst.idempotencyKeys?.includes(opts.idempotency_key)) { + const prior = (inst as any).idempotency?.find?.((i: any) => i.key === opts.idempotency_key) + if (prior && prior.event !== opts.event) throw this.problem('idempotency_conflict', 'same key, different event') + return inst + } + + const candidates = transitionsFromState(tpl, inst.state).filter((t) => t.on === opts.event) + if (!candidates.length) + throw this.problem('invalid_event', `no transition for event ${opts.event} from ${inst.state}`) + const env = GuardEvaluator.envFromInstance(this.toInstanceData(inst)) + const enabled = candidates.filter((t) => GuardEvaluator.evalGuard(t.guard, env, this.config.guardEngine)) + if (!enabled.length) throw this.problem('guard_failed', 'guard evaluated false') + const t = enabled[0] + + let artifactsDelta: Record = {} + let messageId: string | undefined + if (t.action) { + const def = tpl.actions.find((a) => a.key === t.action) + if (!def) throw this.problem('invalid_template', `action not defined: ${t.action}`) + const handler = this.actions.get(def.typeURI) + if (!handler) throw this.problem('action_error', `no handler for type ${def.typeURI}`) + const result = await handler.execute({ + agentContext, + template: tpl, + instance: this.toInstanceData(inst), + action: def, + input: opts.input, + }) + artifactsDelta = result?.artifacts || {} + // local state:set may be applied directly by handler, but ensure we persist + if (result?.contextMerge) inst.context = result.contextMerge + if (result?.messageId) messageId = result.messageId + } + + // Concurrency check: re-read and ensure state didn't change + const fromState = inst.state + try { + const fresh = await this.instanceRepo.getById(agentContext, inst.id) + if (fresh.state !== fromState) throw this.problem('state_conflict', 'state changed concurrently') + } catch { + // ignore if repository throws + } + + // persist atomically (optimistic) + const prevState = inst.state + inst.history.push({ + ts: new Date().toISOString(), + event: opts.event, + from: inst.state, + to: t.to, + actionKey: t.action, + msg_id: messageId, + }) + inst.state = t.to + inst.section = findSectionForState(tpl, t.to) + inst.artifacts = { ...inst.artifacts, ...artifactsDelta } + if (opts.idempotency_key) { + inst.idempotencyKeys = [...(inst.idempotencyKeys || []), opts.idempotency_key] + ;(inst as any).idempotency = [ + ...((inst as any).idempotency || []), + { key: opts.idempotency_key, event: opts.event, to: t.to, actionKey: t.action }, + ] + } + let reachedFinal = false + try { + const toDef = tpl.states.find((s) => s.name === t.to) + if (toDef?.type === 'final') { + inst.status = 'completed' + reachedFinal = true + } + } catch {} + await this.instanceRepo.update(agentContext, inst) + + // Emit state-changed event + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStateChanged, + payload: { + instanceRecord: inst, + previousState: prevState, + newState: inst.state, + event: opts.event, + actionKey: t.action, + msgId: messageId, + }, + }) + } catch {} + + // Emit status-changed & completed, and send Complete message if final + if (reachedFinal) { + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged, + payload: { instanceRecord: inst, previousStatus: 'active', newStatus: 'completed' }, + }) + } catch {} + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceCompleted, + payload: { instanceRecord: inst, state: inst.state, section: inst.section }, + }) + } catch {} + await this.sendCompleteMessage(agentContext, inst) + } + return inst + } + + public async status( + agentContext: AgentContext, + opts: { instance_id: string; include_actions?: boolean; include_ui?: boolean } + ): Promise<{ + instance_id: string + state: string + section?: string + allowed_events: string[] + action_menu: Array<{ label?: string; event: string }> + artifacts: Record + ui?: any[] + }> { + let inst: WorkflowInstanceRecord + try { + inst = await this.instanceRepo.getById(agentContext, opts.instance_id) + } catch { + const found = await this.instanceRepo.getByInstanceId(agentContext, opts.instance_id) + if (!found) throw this.problem('invalid_event', 'instance not found') + inst = found + } + const tplRec = await this.templateRepo.findByTemplateIdAndVersion( + agentContext, + inst.templateId, + inst.templateVersion + ) + if (!tplRec) throw this.problem('invalid_template', 'template not found for instance') + const tpl = tplRec.template + const env = GuardEvaluator.envFromInstance(this.toInstanceData(inst)) + const allowed = transitionsFromState(tpl, inst.state) + .filter((t) => GuardEvaluator.evalGuard(t.guard, env, this.config.guardEngine)) + .map((t) => t.on) + const includeActions = opts.include_actions ?? true + const includeUi = opts.include_ui ?? true + const menu = includeActions + ? ensureArray(tpl.display_hints?.states?.[inst.state]) + .filter((i) => i?.type === 'button' || i?.type === 'submit-button') + .map((i) => ({ label: i?.label, event: i?.event })) + : [] + const ui = includeUi ? ensureArray(tpl.display_hints?.states?.[inst.state]) : undefined + return { + instance_id: inst.instanceId, + state: inst.state, + section: inst.section, + allowed_events: allowed, + action_menu: menu, + artifacts: inst.artifacts, + ...(includeUi ? { ui } : {}), + } + } + + public async pause( + agentContext: AgentContext, + opts: { instance_id: string; reason?: string } + ): Promise { + const inst = await this.getInstanceByIdOrTag(agentContext, opts.instance_id) + const prev = inst.status + inst.status = 'paused' + await this.instanceRepo.update(agentContext, inst) + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged, + payload: { instanceRecord: inst, previousStatus: prev, newStatus: inst.status, reason: opts.reason }, + }) + } catch {} + return inst + } + + public async resume( + agentContext: AgentContext, + opts: { instance_id: string; reason?: string } + ): Promise { + const inst = await this.getInstanceByIdOrTag(agentContext, opts.instance_id) + const prev = inst.status + inst.status = 'active' + await this.instanceRepo.update(agentContext, inst) + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged, + payload: { instanceRecord: inst, previousStatus: prev, newStatus: inst.status, reason: opts.reason }, + }) + } catch {} + return inst + } + + public async cancel( + agentContext: AgentContext, + opts: { instance_id: string; reason?: string } + ): Promise { + const inst = await this.getInstanceByIdOrTag(agentContext, opts.instance_id) + const prev = inst.status + inst.status = 'canceled' + await this.instanceRepo.update(agentContext, inst) + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged, + payload: { instanceRecord: inst, previousStatus: prev, newStatus: inst.status, reason: opts.reason }, + }) + } catch {} + return inst + } + + public async complete( + agentContext: AgentContext, + opts: { instance_id: string; reason?: string } + ): Promise { + const inst = await this.getInstanceByIdOrTag(agentContext, opts.instance_id) + // Only allow completion when FSM is in a final state + const tplRec = await this.templateRepo.findByTemplateIdAndVersion( + agentContext, + inst.templateId, + inst.templateVersion + ) + if (!tplRec) throw this.problem('invalid_template', 'template not found for instance') + const toDef = tplRec.template.states.find((s) => s.name === inst.state) + if (toDef?.type !== 'final') throw this.problem('forbidden', 'cannot complete: state is not final') + const prev = inst.status + inst.status = 'completed' + await this.instanceRepo.update(agentContext, inst) + try { + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceStatusChanged, + payload: { instanceRecord: inst, previousStatus: prev, newStatus: inst.status, reason: opts.reason }, + }) + this.eventEmitter?.emit(agentContext, { + type: WorkflowEventTypes.WorkflowInstanceCompleted, + payload: { instanceRecord: inst, state: inst.state, section: inst.section }, + }) + } catch {} + return inst + } + + public async autoAdvanceByConnection(agentContext: AgentContext, connectionId: string, event: string) { + const inst = await this.instanceRepo.findLatestByConnection(agentContext, connectionId) + if (!inst) return + try { + await this.advance(agentContext, { + instance_id: inst.instanceId, + event, + idempotency_key: `auto:${event}:${inst.instanceId}`, + }) + } catch (e) { + // swallow, log at debug + this.agentConfig.logger.debug(`Workflow autoAdvance error: ${(e as Error).message}`) + } + } + + private evalMultiplicity(expr: string, context: Record): string { + try { + const env = { context, participants: {}, artifacts: {} } + const val = GuardEvaluator.evalValue(expr, env, this.config.guardEngine) + return val != null ? String(val) : '' + } catch { + return '' + } + } + + private toInstanceData(rec: WorkflowInstanceRecord): WorkflowInstanceData { + return { + instance_id: rec.instanceId, + template_id: rec.templateId, + template_version: rec.templateVersion, + connection_id: rec.connectionId, + participants: rec.participants, + state: rec.state, + section: rec.section, + context: rec.context, + artifacts: rec.artifacts, + status: rec.status, + history: rec.history, + multiplicityKeyValue: rec.multiplicityKeyValue, + idempotencyKeys: rec.idempotencyKeys, + } + } + + private problem(code: string, comment: string) { + const err = new Error(comment) + ;(err as any).code = code + return err + } + + private async getInstanceByIdOrTag(agentContext: AgentContext, instanceId: string) { + try { + return await this.instanceRepo.getById(agentContext, instanceId) + } catch { + const found = await this.instanceRepo.getByInstanceId(agentContext, instanceId) + if (!found) throw this.problem('invalid_event', 'instance not found') + return found + } + } + + private uuid(): string { + // light uuid + return `wf_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}` + } + + private validateTemplate(t: WorkflowTemplate) { + const fail = (msg: string) => { + throw this.problem('invalid_template', msg) + } + if (!t.template_id || !t.version || !t.title) fail('missing required fields') + if (!Array.isArray(t.states) || !t.states.length) fail('states required') + if (!Array.isArray(t.transitions)) fail('transitions required') + if (!Array.isArray(t.actions)) fail('actions required') + const stateNames = new Set(t.states.map((s) => s.name)) + if (![...t.states].some((s) => s.type === 'start')) fail('start state required') + for (const s of t.states) { + if (s.section && !t.sections?.some((sec) => sec.name === s.section)) fail(`state.section not found: ${s.section}`) + } + for (const tr of t.transitions) { + if (!stateNames.has(tr.from)) fail(`transition.from unknown: ${tr.from}`) + if (!stateNames.has(tr.to)) fail(`transition.to unknown: ${tr.to}`) + if (tr.action && !t.actions.some((a) => a.key === tr.action)) fail(`transition.action unknown: ${tr.action}`) + } + // profile_ref resolution + for (const a of t.actions) { + if ('profile_ref' in a && (a as any).profile_ref) { + const pr = (a as any).profile_ref as string + if (pr.startsWith('cp.')) { + const key = pr.slice(3) + if (!t.catalog?.credential_profiles || !t.catalog.credential_profiles[key]) fail(`catalog.cp missing: ${key}`) + } else if (pr.startsWith('pp.')) { + const key = pr.slice(3) + if (!t.catalog?.proof_profiles || !t.catalog.proof_profiles[key]) fail(`catalog.pp missing: ${key}`) + } else fail(`invalid profile_ref: ${pr}`) + } + } + } + + private async sendCompleteMessage(agentContext: AgentContext, inst: WorkflowInstanceRecord) { + try { + if (!inst.connectionId) return + const connectionSvc = agentContext.dependencyManager.resolve(DidCommConnectionService) + const messageSender = agentContext.dependencyManager.resolve(DidCommMessageSender) + const connection = await connectionSvc.getById(agentContext, inst.connectionId) + const msg = new CompleteMessage({ + thid: inst.instanceId, + body: { instance_id: inst.instanceId, reason: 'state_final' }, + }) + const outbound = new DidCommOutboundMessageContext(msg as any, { agentContext, connection }) + await messageSender.sendMessage(outbound) + } catch (e) { + this.agentConfig.logger.debug(`Workflow complete notify error: ${(e as Error).message}`) + } + } +} From a254522099833c0dd08d45b22aef1d9cb47ff841 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:36:48 -0400 Subject: [PATCH 09/20] test(workflow): add unit and integration tests Add tests covering engine utilities, protocol handlers, repositories, and service workflows. Signed-off-by: Vinay Singh --- ...tionHandlers.message-id-resolution.spec.ts | 137 ++++++++++++++++ .../src/tests/Engine.utilities.spec.ts | 49 ++++++ .../src/tests/IssueCredentialV2Action.spec.ts | 44 +++++ ...dentialV2Action.to-ref-enforcement.spec.ts | 49 ++++++ .../src/tests/LocalStateSetAction.spec.ts | 29 ++++ .../src/tests/PresentProofV2Action.spec.ts | 72 ++++++++ .../src/tests/ProblemReportHandler.spec.ts | 13 ++ .../tests/ProtocolHandlers.additional.spec.ts | 90 ++++++++++ ...olHandlers.problem-report-controls.spec.ts | 41 +++++ ...colHandlers.problem-report-mapping.spec.ts | 66 ++++++++ .../ProtocolHandlers.status-responses.spec.ts | 73 +++++++++ .../ProtocolMessages.constructors.spec.ts | 71 ++++++++ .../PublishTemplateHandler.success.spec.ts | 29 ++++ .../src/tests/StartHandler.success.spec.ts | 26 +++ .../Status.ui-payload.integration.spec.ts | 86 ++++++++++ .../src/tests/TemplateRefs.valid.spec.ts | 27 +++ .../tests/TemplateSchema.additional.spec.ts | 60 +++++++ .../src/tests/TemplateSchema.branches.spec.ts | 57 +++++++ .../src/tests/TemplateSchema.invalid.spec.ts | 63 +++++++ ...lateValidation.additional-coverage.spec.ts | 86 ++++++++++ .../tests/TemplateValidation.invalid.spec.ts | 50 ++++++ .../workflow/src/tests/WorkflowApi.spec.ts | 72 ++++++++ ...tanceRepository.queries.additional.spec.ts | 22 +++ .../tests/WorkflowInstanceRepository.spec.ts | 15 ++ ...flowModule.inbound-events.extended.spec.ts | 40 +++++ .../WorkflowModule.inbound-events.spec.ts | 111 +++++++++++++ .../tests/WorkflowModule.integration.spec.ts | 154 ++++++++++++++++++ .../WorkflowService.complete-send.spec.ts | 106 ++++++++++++ ...wService.concurrency-and-conflicts.spec.ts | 54 ++++++ .../tests/WorkflowService.edge-cases.spec.ts | 132 +++++++++++++++ .../tests/WorkflowService.lifecycle.spec.ts | 63 +++++++ .../WorkflowService.publish-and-start.spec.ts | 80 +++++++++ .../WorkflowService.status-options.spec.ts | 57 +++++++ ...orkflowService.template-validation.spec.ts | 87 ++++++++++ .../tests/WorkflowTemplateRepository.spec.ts | 16 ++ packages/workflow/src/tests/setup.ts | 3 + 36 files changed, 2230 insertions(+) create mode 100644 packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts create mode 100644 packages/workflow/src/tests/Engine.utilities.spec.ts create mode 100644 packages/workflow/src/tests/IssueCredentialV2Action.spec.ts create mode 100644 packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts create mode 100644 packages/workflow/src/tests/LocalStateSetAction.spec.ts create mode 100644 packages/workflow/src/tests/PresentProofV2Action.spec.ts create mode 100644 packages/workflow/src/tests/ProblemReportHandler.spec.ts create mode 100644 packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts create mode 100644 packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts create mode 100644 packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts create mode 100644 packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts create mode 100644 packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts create mode 100644 packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts create mode 100644 packages/workflow/src/tests/StartHandler.success.spec.ts create mode 100644 packages/workflow/src/tests/Status.ui-payload.integration.spec.ts create mode 100644 packages/workflow/src/tests/TemplateRefs.valid.spec.ts create mode 100644 packages/workflow/src/tests/TemplateSchema.additional.spec.ts create mode 100644 packages/workflow/src/tests/TemplateSchema.branches.spec.ts create mode 100644 packages/workflow/src/tests/TemplateSchema.invalid.spec.ts create mode 100644 packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts create mode 100644 packages/workflow/src/tests/TemplateValidation.invalid.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowApi.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowModule.integration.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.complete-send.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.status-options.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowService.template-validation.spec.ts create mode 100644 packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts create mode 100644 packages/workflow/src/tests/setup.ts diff --git a/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts new file mode 100644 index 0000000000..c54f45eb1e --- /dev/null +++ b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts @@ -0,0 +1,137 @@ +import { IssueCredentialV2Action, PresentProofV2Action } from '../src' + +const makeAgentContext = (mocks: any) => ({ + dependencyManager: { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('CredentialsApi')) return mocks.credentials + if (name.includes('ProofsApi')) return mocks.proofs + if (name.includes('ConnectionService')) return mocks.connections + return {} + }, + }, +}) + +const baseInstance = { + instance_id: 'i1', + template_id: 't1', + template_version: '1.0.0', + connection_id: 'conn1', + participants: { holder: { did: 'did:example:holder' } }, + state: 's', + section: 'Main', + context: {}, + artifacts: {}, + status: 'active' as const, + history: [], +} + +describe('Action handlers message id retrieval', () => { + test('IssueCredentialV2Action uses findOfferMessage id and falls back', async () => { + const action = new IssueCredentialV2Action() + const template: any = { + template_id: 't1', + version: '1.0.0', + title: 'T', + catalog: { + credential_profiles: { + test: { + cred_def_id: 'CREDDEF', + attribute_plan: { name: { source: 'static', value: 'Alice' } }, + to_ref: 'holder', + options: {}, + }, + }, + }, + } + const actionDef: any = { + key: 'offer', + typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', + profile_ref: 'cp.test', + } + // Primary path: findOfferMessage returns message with id + const credsMock1 = { + offerCredential: jest.fn(async () => ({ id: 'rec-1' })), + findOfferMessage: jest.fn(async (_id: string) => ({ message: { id: 'msg-1' } })), + } + const connections = { getById: jest.fn(async () => ({ theirDid: 'did:example:holder' })) } + const ctx1 = { + agentContext: makeAgentContext({ credentials: credsMock1, connections }), + template, + instance: baseInstance, + action: actionDef, + } + const res1 = await action.execute(ctx1 as any) + expect(res1.messageId).toBe('msg-1') + expect(res1.artifacts?.issueRecordId).toBe('rec-1') + // Fallback: findOfferMessage throws → use record id + const credsMock2 = { + offerCredential: jest.fn(async () => ({ id: 'rec-2' })), + findOfferMessage: jest.fn(async (_id: string) => { + throw new Error('not found') + }), + } + const ctx2 = { + agentContext: makeAgentContext({ credentials: credsMock2, connections }), + template, + instance: baseInstance, + action: actionDef, + } + const res2 = await action.execute(ctx2 as any) + expect(res2.messageId).toBe('rec-2') + }) + + test('PresentProofV2Action uses findRequestMessage id and falls back', async () => { + const action = new PresentProofV2Action() + const template: any = { + template_id: 't1', + version: '1.0.0', + title: 'T', + catalog: { + proof_profiles: { + test: { + schema_id: 'SCHEMA', + requested_attributes: ['name'], + requested_predicates: [], + to_ref: 'holder', + options: {}, + }, + }, + }, + } + const actionDef: any = { + key: 'request', + typeURI: 'https://didcomm.org/present-proof/2.0/request-presentation', + profile_ref: 'pp.test', + } + const proofsMock1 = { + requestProof: jest.fn(async () => ({ id: 'prec-1' })), + findRequestMessage: jest.fn(async (_id: string) => ({ message: { id: 'pmsg-1' } })), + } + const connections = { getById: jest.fn(async () => ({ theirDid: 'did:example:holder' })) } + const ctx1 = { + agentContext: makeAgentContext({ proofs: proofsMock1, connections }), + template, + instance: baseInstance, + action: actionDef, + } + const res1 = await action.execute(ctx1 as any) + expect(res1.messageId).toBe('pmsg-1') + expect(res1.artifacts?.proofRecordId).toBe('prec-1') + const proofsMock2 = { + requestProof: jest.fn(async () => ({ id: 'prec-2' })), + findRequestMessage: jest.fn(async (_id: string) => { + throw new Error('not found') + }), + } + const ctx2 = { + agentContext: makeAgentContext({ proofs: proofsMock2, connections }), + template, + instance: baseInstance, + action: actionDef, + } + const res2 = await action.execute(ctx2 as any) + expect(res2.messageId).toBe('prec-2') + }) +}) +import 'reflect-metadata' diff --git a/packages/workflow/src/tests/Engine.utilities.spec.ts b/packages/workflow/src/tests/Engine.utilities.spec.ts new file mode 100644 index 0000000000..e8fc6e5add --- /dev/null +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -0,0 +1,49 @@ +import { AttributePlanner, GuardEvaluator } from '../src' + +describe('Engine helpers', () => { + test('AttributePlanner materialize: context/static/compute', () => { + const plan: any = { + a: { source: 'context', path: 'user.name', required: true }, + b: { source: 'static', value: 42 }, + c: { source: 'compute', expr: 'concat("hi-", "there")' }, + } + const instance: any = { context: { user: { name: 'Alice' } } } + const out = AttributePlanner.materialize(plan, instance) + expect(out).toEqual({ a: 'Alice', b: 42, c: 'hi-there' }) + }) + + test('AttributePlanner required throws missing_attributes', () => { + const plan: any = { req: { source: 'context', path: 'x', required: true } } + const instance: any = { context: {} } + expect(() => AttributePlanner.materialize(plan, instance)).toThrow() + }) + + test('GuardEvaluator evalGuard with JMESPath truthy/falsey', () => { + const env = { context: { a: 1, b: 0 }, participants: {}, artifacts: {} } + expect(GuardEvaluator.evalGuard('context.a', env as any, 'jmespath')).toBe(true) + expect(GuardEvaluator.evalGuard('context.b', env as any, 'jmespath')).toBe(false) + }) + + test('GuardEvaluator evalValue returns selected JSON piece', () => { + const env = { context: { a: { x: 'ok' } }, participants: {}, artifacts: {} } + expect(GuardEvaluator.evalValue('context.a.x', env as any, 'jmespath')).toBe('ok') + }) + + test('GuardEvaluator JS engine evalGuard/evalValue', () => { + const env = { context: { a: 2 }, participants: {}, artifacts: {} } + expect(GuardEvaluator.evalGuard('context.a + 1 > 2', env as any, 'js')).toBe(true) + expect(GuardEvaluator.evalValue('context.a + 2', env as any, 'js')).toBe(4) + }) + + test('AttributePlanner compute error returns undefined unless required', () => { + const badPlan: any = { + x: { source: 'compute', expr: 'invalid++expr' }, + y: { source: 'compute', expr: 'invalid++expr', required: true }, + } + const inst: any = { context: {} } + // x will be omitted silently + expect(() => AttributePlanner.materialize({ x: badPlan.x }, inst)).not.toThrow() + // y required → throws + expect(() => AttributePlanner.materialize({ y: badPlan.y }, inst)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts new file mode 100644 index 0000000000..580a48619b --- /dev/null +++ b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts @@ -0,0 +1,44 @@ +import { IssueCredentialV2Action } from '../src' + +describe('IssueCredentialV2Action', () => { + test('missing profile and connection errors, and wraps thrown errors', async () => { + const action = new IssueCredentialV2Action() + const template: any = { template_id: 't', version: '1', title: 'T', catalog: { credential_profiles: {} } } + const baseCtx: any = { + agentContext: { dependencyManager: { resolve: () => ({}) } }, + template, + instance: { connection_id: 'c1', participants: {} }, + } + // missing profile + await expect( + action.execute({ ...baseCtx, action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' } } as any) + ).rejects.toHaveProperty('code', 'action_error') + // missing connection id + await expect( + action.execute({ + ...baseCtx, + instance: { participants: {} }, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' }, + } as any) + ).rejects.toHaveProperty('code', 'action_error') + // wrap thrown error from creds api + const tpl2: any = { + template_id: 't', + version: '1', + title: 'T', + catalog: { credential_profiles: { test: { cred_def_id: 'C', attribute_plan: {}, to_ref: 'holder' } } }, + } + const credsApi = { + offerCredential: jest.fn(async () => { + throw new Error('boom') + }), + } + const ctx2: any = { + agentContext: { dependencyManager: { resolve: () => credsApi } }, + template: tpl2, + instance: { connection_id: 'c1', participants: {} }, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.test' }, + } + await expect(action.execute(ctx2)).rejects.toHaveProperty('code', 'action_error') + }) +}) diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts new file mode 100644 index 0000000000..f3b8f8a140 --- /dev/null +++ b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata' +import { IssueCredentialV2Action } from '../src' + +describe('to_ref recipient DID enforcement', () => { + test('forbidden when theirDid mismatches participants[to_ref].did', async () => { + const action = new IssueCredentialV2Action() + const template: any = { + template_id: 't', + version: '1.0.0', + title: 'T', + catalog: { credential_profiles: { test: { cred_def_id: 'cd', attribute_plan: {}, to_ref: 'holder' } } }, + } + const instance: any = { + connection_id: 'c1', + participants: { holder: { did: 'did:example:holder' } }, + context: {}, + state: 's', + artifacts: {}, + history: [], + } + const actionDef: any = { + key: 'k', + typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', + profile_ref: 'cp.test', + } + const ctx: any = { + agentContext: { + dependencyManager: { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('ConnectionService')) + return { getById: async () => ({ theirDid: 'did:example:someone-else' }) } + if (name.includes('CredentialsApi')) + return { + offerCredential: async () => { + throw new Error('should-not-send') + }, + } + return {} + }, + }, + }, + template, + instance, + action: actionDef, + } + await expect(action.execute(ctx)).rejects.toHaveProperty('code', 'forbidden') + }) +}) diff --git a/packages/workflow/src/tests/LocalStateSetAction.spec.ts b/packages/workflow/src/tests/LocalStateSetAction.spec.ts new file mode 100644 index 0000000000..82e1f28574 --- /dev/null +++ b/packages/workflow/src/tests/LocalStateSetAction.spec.ts @@ -0,0 +1,29 @@ +import { LocalStateSetAction } from '../src' + +describe('LocalStateSetAction', () => { + test('merges static object', async () => { + const act = new LocalStateSetAction() + const ctx: any = { + action: { key: 'k', typeURI: act.typeUri, staticInput: { merge: { a: { b: 2 } } } }, + instance: { context: { a: { c: 3 } } }, + } + const res = await act.execute(ctx) + expect(res.contextMerge).toEqual({ a: { b: 2, c: 3 } }) + }) + + test('returns {} when merge is unresolved string or non-object', async () => { + const act = new LocalStateSetAction() + // unresolved string path → {} + const ctx1: any = { + action: { staticInput: { merge: '{{ input.form.x }}' }, typeURI: act.typeUri }, + instance: { context: {} }, + input: { form: { x: 1 } }, + } + const res1 = await act.execute(ctx1) + expect(res1).toEqual({}) + // merge provided but not object → {} + const ctx2: any = { action: { staticInput: { merge: 42 }, typeURI: act.typeUri }, instance: { context: {} } } + const res2 = await act.execute(ctx2) + expect(res2).toEqual({}) + }) +}) diff --git a/packages/workflow/src/tests/PresentProofV2Action.spec.ts b/packages/workflow/src/tests/PresentProofV2Action.spec.ts new file mode 100644 index 0000000000..969b892718 --- /dev/null +++ b/packages/workflow/src/tests/PresentProofV2Action.spec.ts @@ -0,0 +1,72 @@ +import { PresentProofV2Action } from '../src' + +describe('PresentProofV2Action', () => { + test('builds predicates and wraps errors', async () => { + const action = new PresentProofV2Action() + const template: any = { + template_id: 't', + version: '1', + title: 'T', + catalog: { + proof_profiles: { + test: { + schema_id: 'S', + requested_attributes: ['name'], + requested_predicates: [{ name: 'age', p_type: '>=', p_value: 18 }], + to_ref: 'holder', + }, + }, + }, + } + const okApi = { + requestProof: jest.fn(async () => ({ id: 'r1' })), + findRequestMessage: jest.fn(async () => ({ message: { id: 'm1' } })), + } + const ctxOk: any = { + agentContext: { dependencyManager: { resolve: () => okApi } }, + template, + instance: { connection_id: 'c1', participants: {} }, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + } + const res = await action.execute(ctxOk) + expect(res.artifacts?.proofRecordId).toBe('r1') + + const badApi = { + requestProof: jest.fn(async () => { + throw new Error('nope') + }), + } + const ctxBad: any = { + agentContext: { dependencyManager: { resolve: () => badApi } }, + template, + instance: { connection_id: 'c1', participants: {} }, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + } + await expect(action.execute(ctxBad)).rejects.toHaveProperty('code', 'action_error') + }) + + test('without restriction (no schema_id/cred_def_id)', async () => { + const action = new PresentProofV2Action() + const template: any = { + template_id: 't', + version: '1', + title: 'T', + catalog: { + proof_profiles: { test: { requested_attributes: ['name'], requested_predicates: [], to_ref: 'holder' } }, + }, + } + const proofsApi = { + requestProof: jest.fn(async () => ({ id: 'r2' })), + findRequestMessage: jest.fn(async () => ({ message: { id: 'm2' } })), + } + const ctx: any = { + agentContext: { dependencyManager: { resolve: () => proofsApi } }, + template, + instance: { connection_id: 'c1', participants: {} }, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + } + const res = await action.execute(ctx) + expect(res.artifacts?.proofRecordId).toBe('r2') + expect(proofsApi.requestProof).toHaveBeenCalled() + }) +}) diff --git a/packages/workflow/src/tests/ProblemReportHandler.spec.ts b/packages/workflow/src/tests/ProblemReportHandler.spec.ts new file mode 100644 index 0000000000..720a56d119 --- /dev/null +++ b/packages/workflow/src/tests/ProblemReportHandler.spec.ts @@ -0,0 +1,13 @@ +import { ProblemReportHandler, ProblemReportMessage } from '../src' + +describe('ProblemReportHandler', () => { + test('logs and returns undefined', async () => { + const handler = new ProblemReportHandler() + const msg = new ProblemReportMessage({ body: { code: 'invalid_event', comment: 'no local instance' }, thid: 't1' }) + const res = await handler.handle({ + agentContext: { dependencyManager: { resolve: (_: any) => ({ logger: { warn() {} } }) } }, + message: msg, + } as any) + expect(res).toBeUndefined() + }) +}) diff --git a/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts new file mode 100644 index 0000000000..76891ef289 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts @@ -0,0 +1,90 @@ +import { + CancelHandler, + CompleteHandler, + PauseHandler, + PublishTemplateHandler, + ResumeHandler, + StatusHandler, + StatusRequestMessage, + WorkflowModuleConfig, +} from '../src' + +const makeAgentContext = () => ({ + dependencyManager: { + resolve: (ctor: any) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + return {} + }, + }, +}) + +const inbound = (message: any) => ({ agentContext: makeAgentContext(), connection: { id: 'c1' }, message }) as any + +describe('Handlers extra cases', () => { + test('PublishTemplateHandler sends problem-report on error', async () => { + const svc = { + publishTemplate: async () => { + throw Object.assign(new Error('bad'), { code: 'invalid_template' }) + }, + } + const handler = new PublishTemplateHandler(svc as any) + const msg: any = { type: 'https://didcomm.org/workflow/1.0/publish-template', body: { template: {} } } + const ctx = await handler.handle(inbound(msg)) + expect((ctx as any)?.message?.body?.code).toBe('invalid_template') + }) + + test('CompleteHandler ignores invalid_event (no local instance)', async () => { + const svc = { + complete: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_event' }) + }, + status: async () => ({}), + } + const handler = new CompleteHandler(svc as any) + const msg: any = { body: { instance_id: 'i1' }, threadId: 'i1' } + const res = await handler.handle(inbound(msg)) + expect(res).toBeUndefined() + }) + + test('Pause/Resume/Cancel ignore invalid_event similarly', async () => { + const svc = { + pause: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_event' }) + }, + resume: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_event' }) + }, + cancel: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_event' }) + }, + status: async () => ({}), + } + const pause = new PauseHandler(svc as any) + const resume = new ResumeHandler(svc as any) + const cancel = new CancelHandler(svc as any) + expect(await pause.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() + expect(await resume.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() + expect(await cancel.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() + }) + + test('StatusHandler forwards include flags', async () => { + const svc = { + status: async (_ctx: any, opts: any) => ({ + instance_id: opts.instance_id, + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + ui: [{}], + }), + } + const handler = new StatusHandler(svc as any) + const message = new StatusRequestMessage({ body: { instance_id: 'i1', include_actions: false, include_ui: true } }) + const ctx = await handler.handle(inbound(message)) + const body = (ctx as any).message.body + expect(body.instance_id).toBe('i1') + expect(Array.isArray(body.ui)).toBe(true) + expect(body.action_menu).toEqual([]) + }) +}) diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts new file mode 100644 index 0000000000..db259e040d --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts @@ -0,0 +1,41 @@ +import { CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '../src' + +const makeCtx = () => ({ + dependencyManager: { + resolve: (ctor: any) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + return {} + }, + }, +}) + +const inbound = (message: any) => ({ agentContext: makeCtx(), connection: { id: 'c1' }, message }) as any + +describe('Handlers problem-report (non-invalid_event)', () => { + test('Cancel/Pause/Resume/Complete return problem-report on error code', async () => { + const svc = { + cancel: async () => { + throw Object.assign(new Error('nope'), { code: 'forbidden' }) + }, + pause: async () => { + throw Object.assign(new Error('nope'), { code: 'action_error' }) + }, + resume: async () => { + throw Object.assign(new Error('nope'), { code: 'guard_failed' }) + }, + complete: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_template' }) + }, + status: async () => ({}), + } + const cancel = new CancelHandler(svc as any) + const pause = new PauseHandler(svc as any) + const resume = new ResumeHandler(svc as any) + const complete = new CompleteHandler(svc as any) + for (const h of [cancel, pause, resume, complete]) { + const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/problem-report') + } + }) +}) diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts new file mode 100644 index 0000000000..41b9c285a8 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts @@ -0,0 +1,66 @@ +import { + AdvanceHandler, + AdvanceMessage, + StartHandler, + StartMessage, + StatusHandler, + StatusRequestMessage, + WorkflowModuleConfig, +} from '../src' + +const makeAgentContext = () => ({ + dependencyManager: { + resolve: (ctor: any) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + return {} + }, + }, +}) + +const makeInbound = (message: any) => + ({ + agentContext: makeAgentContext(), + connection: { id: 'conn1' }, + message, + }) as any + +describe('Handlers problem-report mapping', () => { + test('StartHandler sends problem-report on error', async () => { + const svc = { + start: async () => { + throw Object.assign(new Error('boom'), { code: 'guard_failed' }) + }, + status: async () => ({}), + } + const handler = new StartHandler(svc as any) + const message = new StartMessage({ body: { template_id: 'x' } }) + const res = await handler.handle(makeInbound(message)) + expect((res as any)?.message?.body?.code).toBe('guard_failed') + }) + + test('AdvanceHandler sends problem-report on error', async () => { + const svc = { + advance: async () => { + throw Object.assign(new Error('bad'), { code: 'invalid_event' }) + }, + status: async () => ({}), + } + const handler = new AdvanceHandler(svc as any) + const message = new AdvanceMessage({ body: { instance_id: 'i1', event: 'e' } }) + const res = await handler.handle(makeInbound(message)) + expect((res as any)?.message?.body?.code).toBe('invalid_event') + }) + + test('StatusHandler sends problem-report on error', async () => { + const svc = { + status: async () => { + throw Object.assign(new Error('nope'), { code: 'forbidden' }) + }, + } + const handler = new StatusHandler(svc as any) + const message = new StatusRequestMessage({ body: { instance_id: 'i1' } }) + const res = await handler.handle(makeInbound(message)) + expect((res as any)?.message?.body?.code).toBe('forbidden') + }) +}) diff --git a/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts new file mode 100644 index 0000000000..134c5238f4 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts @@ -0,0 +1,73 @@ +import { + AdvanceHandler, + CancelHandler, + CompleteHandler, + PauseHandler, + ResumeHandler, + WorkflowModuleConfig, +} from '../src' + +const ctx = () => ({ + dependencyManager: { + resolve: (ctor: any) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + return {} + }, + }, +}) + +const inbound = (message: any) => ({ agentContext: ctx(), connection: { id: 'c1' }, message }) as any + +describe('Handlers success responses', () => { + test('Pause/Resume/Cancel → StatusMessage response', async () => { + const status = { instance_id: 'i1', state: 's', allowed_events: [], action_menu: [], artifacts: {} } + const svc = { + pause: jest.fn(async () => ({})), + resume: jest.fn(async () => ({})), + cancel: jest.fn(async () => ({})), + status: jest.fn(async () => status), + } + const pause = new PauseHandler(svc as any) + const resume = new ResumeHandler(svc as any) + const cancel = new CancelHandler(svc as any) + const resP = await pause.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + const resR = await resume.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + const resC = await cancel.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + for (const res of [resP, resR, resC]) { + expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + } + }) + + test('CompleteHandler → StatusMessage response', async () => { + const svc = { + complete: jest.fn(async () => ({})), + status: jest.fn(async () => ({ + instance_id: 'i1', + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + })), + } + const h = new CompleteHandler(svc as any) + const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + }) + + test('AdvanceHandler success with mismatched thid vs instance_id logs warn path', async () => { + const svc = { + advance: jest.fn(async () => ({})), + status: jest.fn(async () => ({ + instance_id: 'i1', + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + })), + } + const h = new AdvanceHandler(svc as any) + const res = await h.handle(inbound({ body: { instance_id: 'i1', event: 'go' }, threadId: 'th-other' })) + expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + }) +}) diff --git a/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts new file mode 100644 index 0000000000..3e7c61b2b6 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts @@ -0,0 +1,71 @@ +import { + CancelMessage, + CompleteMessage, + PauseMessage, + PublishTemplateMessage, + ResumeMessage, + StatusMessage, +} from '../src' + +describe('Protocol messages constructors', () => { + test('Pause/Resume/Cancel/Complete set type, body and thread', () => { + const thid = 't1' + const iid = 'i1' + + const p = new PauseMessage({ thid, body: { instance_id: iid, reason: 'r' } }) + expect(p.type).toBe('https://didcomm.org/workflow/1.0/pause') + expect(p.threadId).toBe(thid) + expect(p.body.instance_id).toBe(iid) + + const r = new ResumeMessage({ thid, body: { instance_id: iid, reason: 'r' } }) + expect(r.type).toBe('https://didcomm.org/workflow/1.0/resume') + expect(r.threadId).toBe(thid) + expect(r.body.instance_id).toBe(iid) + + const c = new CancelMessage({ thid, body: { instance_id: iid, reason: 'r' } }) + expect(c.type).toBe('https://didcomm.org/workflow/1.0/cancel') + expect(c.threadId).toBe(thid) + expect(c.body.instance_id).toBe(iid) + + const done = new CompleteMessage({ thid, body: { instance_id: iid, reason: 'ok' } }) + expect(done.type).toBe('https://didcomm.org/workflow/1.0/complete') + expect(done.threadId).toBe(thid) + expect(done.body.instance_id).toBe(iid) + }) + + test('StatusMessage body shape is preserved', () => { + const thid = 't2' + const msg = new StatusMessage({ + thid, + body: { + instance_id: 'i2', + state: 's', + section: 'Main', + allowed_events: ['a', 'b'], + action_menu: [{ label: 'L', event: 'E' }], + artifacts: { k: 'v' }, + ui: [{ type: 'text', text: 'hello' }], + }, + }) + expect(msg.type).toBe('https://didcomm.org/workflow/1.0/status') + expect(msg.threadId).toBe(thid) + expect(msg.body.allowed_events).toContain('a') + expect(msg.body.ui?.[0]).toEqual({ type: 'text', text: 'hello' }) + }) + + test('PublishTemplateMessage sets type and embeds template', () => { + const tpl: any = { + template_id: 't', + version: '1.0.0', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + const m = new PublishTemplateMessage({ body: { template: tpl, mode: 'upsert' } }) + expect(m.type).toBe('https://didcomm.org/workflow/1.0/publish-template') + expect((m.body as any).template.template_id).toBe('t') + }) +}) diff --git a/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts new file mode 100644 index 0000000000..b64f0a6d0b --- /dev/null +++ b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts @@ -0,0 +1,29 @@ +import { PublishTemplateHandler } from '../src' + +describe('PublishTemplateHandler success', () => { + test('returns undefined on success (no outbound message)', async () => { + const svc = { publishTemplate: jest.fn(async () => {}) } + const handler = new PublishTemplateHandler(svc as any) + const msg: any = { + body: { + template: { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + }, + }, + } + const res = await handler.handle({ + agentContext: { + dependencyManager: { resolve: (_ctor: any) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + }, + message: msg, + } as any) + expect(res).toBeUndefined() + }) +}) diff --git a/packages/workflow/src/tests/StartHandler.success.spec.ts b/packages/workflow/src/tests/StartHandler.success.spec.ts new file mode 100644 index 0000000000..6190954c2a --- /dev/null +++ b/packages/workflow/src/tests/StartHandler.success.spec.ts @@ -0,0 +1,26 @@ +import { StartHandler } from '../src' + +describe('StartHandler success', () => { + test('returns StatusMessage on success', async () => { + const record = { instanceId: 'i1' } + const svc = { + start: jest.fn(async () => record), + status: jest.fn(async () => ({ + instance_id: 'i1', + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + })), + } + const handler = new StartHandler(svc as any) + const res = await handler.handle({ + agentContext: { + dependencyManager: { resolve: (_ctor: any) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + }, + connection: { id: 'c1' }, + message: { body: { template_id: 't' }, id: 'id1' }, + } as any) + expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + }) +}) diff --git a/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts new file mode 100644 index 0000000000..45e7b6943c --- /dev/null +++ b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts @@ -0,0 +1,86 @@ +import { AskarModule } from '@credo-ts/askar' +import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { agentDependencies } from '@credo-ts/node' +import { askar } from '@openwallet-foundation/askar-nodejs' +import { WorkflowModule } from '../src' + +const makeAgent = async () => { + const agent = new Agent({ + config: { + label: 'wf-status-ui-test', + logger: new ConsoleLogger(LogLevel.off), + walletConfig: { id: 'wf-status-ui-test', key: 'wf-status-ui-test' }, + }, + dependencies: agentDependencies, + modules: { + askar: new AskarModule({ + askar, + store: { + id: 'wf-status-ui-store', + key: 'wf-status-ui-store', + database: { type: 'sqlite', config: { inMemory: true } }, + }, + }), + workflow: new WorkflowModule({ guardEngine: 'jmespath' }), + }, + }) + await agent.initialize() + return agent +} + +describe('Status UI payload', () => { + test('status returns action_menu and full ui items when requested', async () => { + const agent = await makeAgent() + const ui = [ + { type: 'text', text: 'Welcome!' }, + { type: 'image', url: 'https://example.com/banner.png' }, + { type: 'video', url: 'https://example.com/intro.mp4' }, + { type: 'input', name: 'email', inputType: 'email', label: 'Email' }, + { type: 'check-box', name: 'agree', label: 'I agree' }, + { type: 'drop-down', name: 'country', options: ['US', 'CA'] }, + { type: 'button', label: 'Go', event: 'go' }, + { type: 'submit-button', label: 'Submit', event: 'submit' }, + ] + const tpl = { + template_id: 'ui-tpl', + version: '1.0.0', + title: 'UI Demo', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [ + { name: 'menu', type: 'start', section: 'Main' }, + { name: 'done', type: 'final', section: 'Main' }, + ], + transitions: [], + catalog: {}, + actions: [], + display_hints: { states: { menu: ui } }, + } + await agent.modules.workflow.publishTemplate(tpl as any) + const inst = await agent.modules.workflow.start({ template_id: 'ui-tpl' }) + + const s1 = await agent.modules.workflow.status({ instance_id: inst.instanceId, include_ui: true }) + expect(s1.state).toBe('menu') + // action_menu only contains button and submit-button + expect(s1.action_menu).toEqual( + expect.arrayContaining([ + { label: 'Go', event: 'go' }, + { label: 'Submit', event: 'submit' }, + ]) + ) + // full ui mirror + expect(s1.ui).toEqual(ui) + + const s2 = await agent.modules.workflow.status({ + instance_id: inst.instanceId, + include_ui: true, + include_actions: false, + }) + expect(s2.action_menu.length).toBe(0) + expect(s2.ui).toEqual(ui) + + const s3 = await agent.modules.workflow.status({ instance_id: inst.instanceId, include_actions: true }) + expect(s3.ui).toEqual(ui) + await agent.shutdown() + }) +}) diff --git a/packages/workflow/src/tests/TemplateRefs.valid.spec.ts b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts new file mode 100644 index 0000000000..06242251dc --- /dev/null +++ b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts @@ -0,0 +1,27 @@ +import { validateTemplateRefs } from '../src' + +describe('validateTemplateRefs positive (cp.* and pp.*)', () => { + test('cp.* and pp.* profile_ref resolve to catalog entries', () => { + const tpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [{ name: 'a', type: 'start', section: 'Main' }], + transitions: [ + { from: 'a', to: 'a', on: 'go', action: 'issue' }, + { from: 'a', to: 'a', on: 'ask', action: 'proof' }, + ], + catalog: { + credential_profiles: { test: { cred_def_id: 'C', attribute_plan: {}, to_ref: 'holder' } }, + proof_profiles: { demo: { requested_attributes: [], requested_predicates: [], to_ref: 'holder' } }, + }, + actions: [ + { key: 'issue', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.test' }, + { key: 'proof', typeURI: 'https://didcomm.org/present-proof/2.0/request-presentation', profile_ref: 'pp.demo' }, + ], + } + expect(() => validateTemplateRefs(tpl)).not.toThrow() + }) +}) diff --git a/packages/workflow/src/tests/TemplateSchema.additional.spec.ts b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts new file mode 100644 index 0000000000..35dd005f2a --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts @@ -0,0 +1,60 @@ +import { validateTemplateJson } from '../src' + +describe('Schemas extra invalid cases', () => { + test('invalid instance_policy (missing mode)', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: {}, + states: [], + transitions: [], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('transitions invalid item shape', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [{ foo: 'bar' }], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('actions invalid profile_ref pattern rejected by schema', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [{ key: 'a', typeURI: 'x', profile_ref: 'zz.x' }], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('sections invalid item (missing name)', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{}], + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/TemplateSchema.branches.spec.ts b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts new file mode 100644 index 0000000000..ef61c492f5 --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts @@ -0,0 +1,57 @@ +import { validateTemplateJson } from '../src' + +const base = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 'a', type: 'start' as const }], + transitions: [], + catalog: {}, + actions: [], +} + +describe('schemas.ts JSON-schema branches', () => { + test('root additionalProperties=false', () => { + const bad: any = { ...base, foo: 'bar' } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('instance_policy additionalProperties=false', () => { + const bad: any = { ...base, instance_policy: { mode: 'multi_per_connection', extra: 1 } } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('transitions item additionalProperties=false', () => { + const bad: any = { ...base, transitions: [{ from: 'a', to: 'a', on: 'x', extra: true }] } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('credential_profiles entry additionalProperties=false', () => { + const bad: any = { + ...base, + catalog: { credential_profiles: { x: { cred_def_id: 'C', attribute_plan: {}, to_ref: 'holder', extra: 'no' } } }, + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('proof_profiles entry additionalProperties=false', () => { + const bad: any = { + ...base, + catalog: { proof_profiles: { x: { to_ref: 'holder', extra: 'no' } } }, + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('requested_predicates item additionalProperties=false', () => { + const bad: any = { + ...base, + catalog: { + proof_profiles: { + x: { to_ref: 'holder', requested_predicates: [{ name: 'age', p_type: '>=', p_value: 18, foo: 'bar' }] }, + }, + }, + } + expect(() => validateTemplateJson(bad)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts new file mode 100644 index 0000000000..b86d612f84 --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts @@ -0,0 +1,63 @@ +import { validateTemplateJson } from '../src' + +describe('schemas.ts more invalids', () => { + test('transitions missing from', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [{ to: 'a', on: 'go' }], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('transitions missing to', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [{ from: 'a', on: 'go' }], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('states item missing name', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('proof requested_predicates item missing p_value', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: { + proof_profiles: { + p: { requested_attributes: [], requested_predicates: [{ name: 'age', p_type: '>=' }], to_ref: 'holder' }, + }, + }, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts new file mode 100644 index 0000000000..5a4a92be1b --- /dev/null +++ b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts @@ -0,0 +1,86 @@ +import { validateTemplateJson, validateTemplateRefs } from '../src' + +describe('schemas.ts additional coverage', () => { + test('transitions missing on', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [{ from: 'a', to: 'a' }], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('state missing type', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a' }], + transitions: [], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('attribute_plan invalid variants', () => { + const base: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: { credential_profiles: {} }, + actions: [], + } + // context missing path + const bad1 = { + ...base, + catalog: { + credential_profiles: { + x: { cred_def_id: 'C', attribute_plan: { name: { source: 'context' } }, to_ref: 'holder' }, + }, + }, + } + const bad2 = { + ...base, + catalog: { + credential_profiles: { + x: { cred_def_id: 'C', attribute_plan: { name: { source: 'static' } }, to_ref: 'holder' }, + }, + }, + } + const bad3 = { + ...base, + catalog: { + credential_profiles: { + x: { cred_def_id: 'C', attribute_plan: { name: { source: 'compute' } }, to_ref: 'holder' }, + }, + }, + } + expect(() => validateTemplateJson(bad1)).toThrow() + expect(() => validateTemplateJson(bad2)).toThrow() + expect(() => validateTemplateJson(bad3)).toThrow() + }) + + test('validateTemplateRefs transition.from unknown', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [{ from: 'x', to: 'a', on: 'go' }], + catalog: {}, + actions: [], + } + expect(() => validateTemplateRefs(bad)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts new file mode 100644 index 0000000000..a437e9d885 --- /dev/null +++ b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts @@ -0,0 +1,50 @@ +import { validateTemplateJson, validateTemplateRefs } from '../src' + +const baseTpl = { + template_id: 't', + version: '1.0.0', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [{ name: 's', type: 'start', section: 'Main' }], + transitions: [], + catalog: {}, + actions: [], +} + +describe('Template invalid cases (schema/refs)', () => { + test('missing start state (passes JSON schema, fails refs)', () => { + const bad = { ...baseTpl, states: [{ name: 'x', type: 'normal' }] } + expect(() => validateTemplateJson(bad as any)).not.toThrow() + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) + + test('unknown state.section', () => { + const bad = { ...baseTpl, states: [{ name: 's', type: 'start', section: 'Unknown' }] } + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) + + test('transition.action unknown', () => { + const bad = { ...baseTpl, transitions: [{ from: 's', to: 's', on: 'go', action: 'missing' }] } + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) + + test('transition.to unknown', () => { + const bad = { + ...baseTpl, + states: [{ name: 's', type: 'start', section: 'Main' }], + transitions: [{ from: 's', to: 'x', on: 'go' }], + } + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) + + test('profile_ref missing in catalog (cp.)', () => { + const bad = { ...baseTpl, actions: [{ key: 'a', typeURI: 'x', profile_ref: 'cp.unknown' }] } + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) + + test('profile_ref missing in catalog (pp.)', () => { + const bad = { ...baseTpl, actions: [{ key: 'a', typeURI: 'x', profile_ref: 'pp.unknown' }] } + expect(() => validateTemplateRefs(bad as any)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowApi.spec.ts b/packages/workflow/src/tests/WorkflowApi.spec.ts new file mode 100644 index 0000000000..b1e42757ba --- /dev/null +++ b/packages/workflow/src/tests/WorkflowApi.spec.ts @@ -0,0 +1,72 @@ +import { WorkflowApi } from '../src' + +describe('WorkflowApi pass-through', () => { + const make = () => { + const service = { + publishTemplate: jest.fn(async (_ctx: any, t: any) => ({ id: 'tpl', template: t })), + start: jest.fn(async (_ctx: any, o: any) => ({ id: o.instance_id || 'id1', instanceId: 'i1' })), + advance: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + status: jest.fn(async (_ctx: any, o: any) => ({ + instance_id: o.instance_id, + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + })), + pause: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + resume: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + cancel: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + complete: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + } + const agentContext: any = {} + const api = new WorkflowApi(service as any, agentContext) + return { api, service } + } + + test('publishes template', async () => { + const { api, service } = make() + const tpl: any = { + template_id: 't', + version: '1.0.0', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + await api.publishTemplate(tpl) + expect(service.publishTemplate).toHaveBeenCalled() + }) + + test('start/advance/status pipelines through', async () => { + const { api, service } = make() + await api.start({ template_id: 't', connection_id: 'c1' }) + expect(service.start).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ template_id: 't', connection_id: 'c1' }) + ) + await api.advance({ instance_id: 'i1', event: 'go' }) + expect(service.advance).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ instance_id: 'i1', event: 'go' }) + ) + await api.status({ instance_id: 'i1', include_actions: false, include_ui: true }) + expect(service.status).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ instance_id: 'i1', include_actions: false, include_ui: true }) + ) + }) + + test('pause/resume/cancel/complete', async () => { + const { api, service } = make() + await api.pause({ instance_id: 'i1' }) + await api.resume({ instance_id: 'i1' }) + await api.cancel({ instance_id: 'i1' }) + await api.complete({ instance_id: 'i1' }) + expect(service.pause).toHaveBeenCalled() + expect(service.resume).toHaveBeenCalled() + expect(service.cancel).toHaveBeenCalled() + expect(service.complete).toHaveBeenCalled() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts new file mode 100644 index 0000000000..2ee8537aea --- /dev/null +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts @@ -0,0 +1,22 @@ +import { WorkflowInstanceRepository } from '../src' + +describe('WorkflowInstanceRepository filters', () => { + test('findByTemplateConnAndMultiplicity passes all filters', async () => { + const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) + const spy = jest.spyOn(repo as any, 'findByQuery').mockResolvedValue([{ id: 'x' }]) + const out = await repo.findByTemplateConnAndMultiplicity({} as any, 'tpl', 'conn', 'K') + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ templateId: 'tpl', connectionId: 'conn', multiplicityKeyValue: 'K' }) + ) + expect(out[0].id).toBe('x') + }) + + test('getByInstanceId calls findSingleByQuery', async () => { + const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) + const spy = jest.spyOn(repo as any, 'findSingleByQuery').mockResolvedValue({ id: 'y' }) + const rec = await repo.getByInstanceId({} as any, 'inst') + expect(spy).toHaveBeenCalledWith(expect.anything(), { instanceId: 'inst' }) + expect(rec.id).toBe('y') + }) +}) diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts new file mode 100644 index 0000000000..75528c98e4 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts @@ -0,0 +1,15 @@ +import { WorkflowInstanceRepository } from '../src' + +describe('WorkflowInstanceRepository helpers', () => { + test('findLatestByConnection returns most recent by updatedAt/createdAt', async () => { + const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) + const list: any[] = [ + { id: 'a', updatedAt: new Date('2020-01-01'), createdAt: new Date('2020-01-01') }, + { id: 'b', updatedAt: new Date('2021-01-01'), createdAt: new Date('2020-06-01') }, + { id: 'c', createdAt: new Date('2022-01-01') }, + ] + jest.spyOn(repo as any, 'findByConnection').mockResolvedValue(list) + const latest = await repo.findLatestByConnection({} as any, 'c1') + expect(latest?.id).toBe('c') + }) +}) diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts new file mode 100644 index 0000000000..da7fcd893d --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts @@ -0,0 +1,40 @@ +import { + DidCommCredentialEventTypes, + DidCommCredentialState, + DidCommProofEventTypes, + DidCommProofState, +} from '@credo-ts/didcomm' +import { WorkflowModule } from '../src' + +describe('WorkflowModule event mapping (Done branches)', () => { + test('maps Done branches for credentials and proofs', async () => { + const module = new WorkflowModule({}) + const listeners: Record = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + if (name.includes('FeatureRegistry')) return { register: () => {} } + if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: () => {} } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: Function) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as any) + listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ + payload: { credentialExchangeRecord: { connectionId: 'c', state: DidCommCredentialState.Done } }, + }) + listeners[DidCommProofEventTypes.ProofStateChanged]?.({ + payload: { proofRecord: { connectionId: 'c', state: DidCommProofState.Done } }, + }) + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c', 'issued_ack') + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c', 'verified_ack') + }) +}) diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts new file mode 100644 index 0000000000..852dadcc2f --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts @@ -0,0 +1,111 @@ +import { + DidCommCredentialEventTypes, + DidCommCredentialState, + DidCommProofEventTypes, + DidCommProofState, +} from '@credo-ts/didcomm' +import { WorkflowModule } from '../src' + +describe('WorkflowModule event mapping', () => { + test('maps credential/proof events to workflow autoAdvance', async () => { + const module = new WorkflowModule({}) + const handlers: any[] = [] + const listeners: Record = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + if (name.includes('FeatureRegistry')) return { register: () => {} } + if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: (h: any) => handlers.push(h) } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: Function) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as any) + listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ + payload: { credentialExchangeRecord: { connectionId: 'c1', state: DidCommCredentialState.RequestReceived } }, + }) + listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ + payload: { credentialExchangeRecord: { connectionId: 'c1', state: DidCommCredentialState.Done } }, + }) + listeners[DidCommProofEventTypes.ProofStateChanged]?.({ + payload: { proofRecord: { connectionId: 'c2', state: DidCommProofState.PresentationReceived } }, + }) + listeners[DidCommProofEventTypes.ProofStateChanged]?.({ + payload: { proofRecord: { connectionId: 'c2', state: DidCommProofState.Done } }, + }) + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c1', 'request_received') + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c1', 'issued_ack') + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c2', 'presentation_received') + expect(service.autoAdvanceByConnection).toHaveBeenCalledWith(expect.anything(), 'c2', 'verified_ack') + }) + + test('ignores events without connectionId', async () => { + const module = new WorkflowModule({}) + const handlers: any[] = [] + const listeners: Record = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + if (name.includes('FeatureRegistry')) return { register: () => {} } + if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: (h: any) => handlers.push(h) } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: Function) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as any) + // Fire events with no connectionId + listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ + payload: { credentialExchangeRecord: { state: DidCommCredentialState.RequestReceived } }, + }) + listeners[DidCommProofEventTypes.ProofStateChanged]?.({ + payload: { proofRecord: { state: DidCommProofState.PresentationReceived } }, + }) + expect(service.autoAdvanceByConnection).not.toHaveBeenCalled() + }) + + test('no-op for unrelated states (neither mapped branch)', async () => { + const module = new WorkflowModule({}) + const listeners: Record = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + resolve: (ctor: any) => { + const name = ctor?.name || '' + if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } + if (name.includes('FeatureRegistry')) return { register: () => {} } + if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: () => {} } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: Function) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as any) + listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ + payload: { credentialExchangeRecord: { connectionId: 'c', state: 'offer-sent' } }, + }) + listeners[DidCommProofEventTypes.ProofStateChanged]?.({ + payload: { proofRecord: { connectionId: 'c', state: 'proposal-sent' } }, + }) + expect(service.autoAdvanceByConnection).not.toHaveBeenCalled() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowModule.integration.spec.ts b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts new file mode 100644 index 0000000000..0bbb9fbb6b --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts @@ -0,0 +1,154 @@ +import { AskarModule } from '@credo-ts/askar' +import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { agentDependencies } from '@credo-ts/node' +import { askar } from '@openwallet-foundation/askar-nodejs' +import { WorkflowModule } from '../src' + +const makeAgent = async () => { + const agent = new Agent({ + config: { + label: 'wf-test', + logger: new ConsoleLogger(LogLevel.off), + walletConfig: { id: 'wf-test', key: 'wf-test' }, + }, + dependencies: agentDependencies, + modules: { + askar: new AskarModule({ + askar, + store: { id: 'wf-store', key: 'wf-store', database: { type: 'sqlite', config: { inMemory: true } } }, + }), + workflow: new WorkflowModule({ guardEngine: 'jmespath' }), + }, + }) + await agent.initialize() + return agent +} + +describe('Workflow module', () => { + test('publishTemplate validates schema and refs', async () => { + const agent = await makeAgent() + const tpl = { + template_id: 't1', + version: '1.0.0', + title: 'Demo', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [ + { name: 'start', type: 'start', section: 'Main' }, + { name: 'end', type: 'final', section: 'Main' }, + ], + transitions: [{ from: 'start', to: 'end', on: 'go' }], + catalog: {}, + actions: [], + } + await agent.modules.workflow.publishTemplate(tpl as any) + await agent.shutdown() + }) + + test('publishTemplate invalid (missing start state) throws', async () => { + const agent = await makeAgent() + const bad = { + template_id: 'bad', + version: '1.0.0', + title: 'Bad', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'x', type: 'normal' }], + transitions: [], + catalog: {}, + actions: [], + } + await expect(agent.modules.workflow.publishTemplate(bad as any)).rejects.toHaveProperty('code', 'invalid_template') + await agent.shutdown() + }) + + test('singleton_per_connection returns existing instance', async () => { + const agent = await makeAgent() + const tpl = { + template_id: 'single', + version: '1.0.0', + title: 'S', + instance_policy: { mode: 'singleton_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + await agent.modules.workflow.publishTemplate(tpl as any) + const a = await agent.modules.workflow.start({ template_id: 'single', connection_id: 'conn-1' }) + const b = await agent.modules.workflow.start({ template_id: 'single', connection_id: 'conn-1' }) + expect(a.instanceId).toBe(b.instanceId) + await agent.shutdown() + }) + + test('multi_per_connection multiplicity_key dedupes per key', async () => { + const agent = await makeAgent() + const tpl = { + template_id: 'multi', + version: '1.0.0', + title: 'M', + instance_policy: { mode: 'multi_per_connection', multiplicity_key: 'context.k' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + await agent.modules.workflow.publishTemplate(tpl as any) + const a = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'A' } }) + const b = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'A' } }) + const c = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'B' } }) + expect(a.instanceId).toBe(b.instanceId) + expect(c.instanceId).not.toBe(a.instanceId) + await agent.shutdown() + }) + + test('guards, local action, final state, idempotency', async () => { + const agent = await makeAgent() + const tpl = { + template_id: 'flow', + version: '1.0.0', + title: 'F', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [ + { name: 'menu', type: 'start', section: 'Main' }, + { name: 'done', type: 'final', section: 'Main' }, + ], + transitions: [ + { from: 'menu', to: 'menu', on: 'save', action: 'state_save_form' }, + { from: 'menu', to: 'done', on: 'finish', guard: 'context.name' }, + ], + catalog: {}, + actions: [ + { + key: 'state_save_form', + typeURI: 'https://didcomm.org/workflow/actions/state:set@1', + staticInput: { merge: '{{ input.form }}' }, + }, + ], + } + await agent.modules.workflow.publishTemplate(tpl as any) + const inst = await agent.modules.workflow.start({ template_id: 'flow', context: {} }) + const s1 = await agent.modules.workflow.status({ instance_id: inst.instanceId }) + expect(s1.allowed_events.includes('finish')).toBe(false) + await agent.modules.workflow.advance({ + instance_id: inst.instanceId, + event: 'save', + idempotency_key: 'k1', + input: { form: { name: 'Alice' } }, + }) + const s2 = await agent.modules.workflow.status({ instance_id: inst.instanceId }) + expect(s2.allowed_events.includes('finish')).toBe(true) + await agent.modules.workflow.advance({ + instance_id: inst.instanceId, + event: 'save', + idempotency_key: 'k1', + input: { form: { name: 'Alice' } }, + }) + const s3 = await agent.modules.workflow.status({ instance_id: inst.instanceId }) + expect(s3.state).toBe('menu') + await agent.modules.workflow.advance({ instance_id: inst.instanceId, event: 'finish', idempotency_key: 'k2' }) + const s4 = await agent.modules.workflow.status({ instance_id: inst.instanceId }) + expect(s4.state).toBe('done') + await agent.shutdown() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts new file mode 100644 index 0000000000..4c7e5a9324 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts @@ -0,0 +1,106 @@ +import { WorkflowService } from '../src' + +const baseTpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [ + { name: 'a', type: 'start' }, + { name: 'b', type: 'final' }, + ], + transitions: [{ from: 'a', to: 'b', on: 'go' }], + catalog: {}, + actions: [], +} + +describe('sendCompleteMessage via advance to final', () => { + test('sends Complete when connectionId present (success path)', async () => { + const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: baseTpl }) } as any + let inst: any = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'active', + context: {}, + artifacts: {}, + history: [], + connectionId: 'conn1', + } + const instanceRepo = { + getById: async () => inst, + getByInstanceId: async () => inst, + update: async (_ctx: any, rec: any) => { + inst = rec + }, + } as any + const config = { guardEngine: 'jmespath', enableProblemReport: true } as any + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any + const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) + + // agentContext returns connection service + message sender + const connection = { id: 'conn1' } + const connectionSvc = { getById: jest.fn(async () => connection) } + const messageSender = { sendMessage: jest.fn(async () => {}) } + const agentContext: any = { + dependencyManager: { + resolve: (ctor: any) => { + if ((ctor?.name || '').includes('ConnectionService')) return connectionSvc + if ((ctor?.name || '').includes('MessageSender')) return messageSender + return {} + }, + }, + } + + await svc.advance(agentContext, { instance_id: 'i', event: 'go' }) + expect(messageSender.sendMessage).toHaveBeenCalled() + }) + + test('swallows errors during Complete notify (debug logged)', async () => { + const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: baseTpl }) } as any + let inst: any = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'active', + context: {}, + artifacts: {}, + history: [], + connectionId: 'conn1', + } + const instanceRepo = { + getById: async () => inst, + getByInstanceId: async () => inst, + update: async (_ctx: any, rec: any) => { + inst = rec + }, + } as any + const config = { guardEngine: 'jmespath', enableProblemReport: true } as any + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any + const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) + + const connectionSvc = { + getById: jest.fn(async () => { + throw new Error('nope') + }), + } + const messageSender = { sendMessage: jest.fn(async () => {}) } + const agentContext: any = { + dependencyManager: { + resolve: (ctor: any) => { + if ((ctor?.name || '').includes('ConnectionService')) return connectionSvc + if ((ctor?.name || '').includes('MessageSender')) return messageSender + return {} + }, + }, + } + + await svc.advance(agentContext, { instance_id: 'i', event: 'go' }) + // error is swallowed and logged at debug + expect(agentConfig.logger.debug).toHaveBeenCalled() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts new file mode 100644 index 0000000000..32b0e5a231 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts @@ -0,0 +1,54 @@ +import { WorkflowService } from '../src' + +describe('WorkflowService concurrency conflict', () => { + test('advance throws state_conflict when state changed concurrently', async () => { + const tpl: any = { + template_id: 't', + version: '1.0.0', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [ + { name: 'a', type: 'start' }, + { name: 'b', type: 'final' }, + ], + transitions: [{ from: 'a', to: 'b', on: 'go' }], + catalog: {}, + actions: [], + } + const tplRepo = { findByTemplateIdAndVersion: async () => ({ template: tpl }) } as any + const inst = { + id: 'i1', + instanceId: 'i1', + templateId: 't', + templateVersion: '1.0.0', + state: 'a', + section: undefined, + context: {}, + artifacts: {}, + status: 'active', + history: [], + idempotencyKeys: [], + } + let count = 0 + const getById = jest.fn(async () => { + count += 1 + return count === 2 ? { ...inst, state: 'x' } : inst + }) + const instRepo = { getById, getByInstanceId: async () => inst, update: async () => {} } as any + const svc = new WorkflowService( + tplRepo, + instRepo, + { + guardEngine: 'jmespath', + autoReturnExistingOnSingleton: true, + actionTimeoutMs: 15000, + enableProblemReport: true, + } as any, + { logger: { debug() {}, info() {} } } as any + ) + try { + await svc.advance({} as any, { instance_id: 'i1', event: 'go' }) + } catch {} + expect(getById).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts new file mode 100644 index 0000000000..9043f2b770 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts @@ -0,0 +1,132 @@ +import { WorkflowService, WorkflowTemplateRecord } from '../src' + +describe('WorkflowService additional coverage', () => { + const makeSvc = (overrides: any = {}) => { + const tpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection', multiplicity_key: overrides.multiplicity_key }, + states: [ + { name: 'a', type: 'start' }, + { name: 'b', type: 'final' }, + ], + transitions: [{ from: 'a', to: 'b', on: 'go' }], + catalog: {}, + actions: [], + } + const templateRepo = { + findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl }) as WorkflowTemplateRecord), + update: jest.fn(), + save: jest.fn(), + } as any + const instanceRepo = { + getById: jest.fn(), + getByInstanceId: jest.fn(), + update: jest.fn(), + save: jest.fn(), + findByTemplateAndConnection: jest.fn(async () => []), + findByTemplateConnAndMultiplicity: jest.fn(async () => []), + findLatestByConnection: jest.fn(), + } as any + const eventEmitter = { emit: jest.fn() } as any + const config = { + guardEngine: overrides.engine || 'jmespath', + autoReturnExistingOnSingleton: true, + enableProblemReport: true, + } as any + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any + const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig, eventEmitter) + return { svc, templateRepo, instanceRepo, eventEmitter, agentConfig } + } + + test('pause/resume/cancel emit status-changed and update status', async () => { + const { svc, instanceRepo, eventEmitter } = makeSvc() + const inst: any = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', status: 'active' } + instanceRepo.getById.mockResolvedValue(inst) + await svc.pause({} as any, { instance_id: 'i' }) + expect(inst.status).toBe('paused') + expect(eventEmitter.emit).toHaveBeenCalled() + + inst.status = 'paused' + await svc.resume({} as any, { instance_id: 'i' }) + expect(inst.status).toBe('active') + + inst.status = 'active' + await svc.cancel({} as any, { instance_id: 'i' }) + expect(inst.status).toBe('canceled') + }) + + test('status throws invalid_event when instance not found', async () => { + const { svc, instanceRepo } = makeSvc() + instanceRepo.getById.mockRejectedValue(new Error('not found')) + instanceRepo.getByInstanceId.mockResolvedValue(null) + await expect(svc.status({} as any, { instance_id: 'missing' })).rejects.toHaveProperty('code', 'invalid_event') + }) + + test('status falls back to getByInstanceId when getById fails', async () => { + const { svc, instanceRepo } = makeSvc() + const inst = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'active', + participants: {}, + context: {}, + artifacts: {}, + history: [], + } + instanceRepo.getById.mockRejectedValue(new Error('nope')) + instanceRepo.getByInstanceId.mockResolvedValue(inst) + const r = await svc.status({} as any, { instance_id: 'i' }) + expect(r.instance_id).toBe('i') + }) + + test('autoAdvanceByConnection swallows errors and logs debug', async () => { + const { svc, instanceRepo, agentConfig } = makeSvc() + instanceRepo.findLatestByConnection.mockResolvedValue({ + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'completed', + }) + await svc.autoAdvanceByConnection({} as any, 'c1', 'request_received') + expect(agentConfig.logger.debug).toHaveBeenCalled() + }) + + test('start with invalid JS multiplicity_key results in empty multiplicityKeyValue', async () => { + const { svc, instanceRepo } = makeSvc({ multiplicity_key: 'context.k', engine: 'js' }) + // Spy on GuardEvaluator.evalValue to throw to hit evalMultiplicity catch + const Guard = require('../src').GuardEvaluator + jest.spyOn(Guard, 'evalValue').mockImplementation(() => { + throw new Error('bang') + }) + let saved: any + instanceRepo.save.mockImplementation(async (_ctx: any, rec: any) => { + saved = rec + }) + await svc.start({} as any, { template_id: 't', connection_id: 'c1', context: {} }) + expect(saved.multiplicityKeyValue).toBe('') + ;(Guard.evalValue as jest.Mock).mockRestore() + }) + + test('getInstanceByIdOrTag throws invalid_event when neither id nor tag resolves', async () => { + const { svc, instanceRepo } = makeSvc() + instanceRepo.getById.mockRejectedValue(new Error('boom')) + instanceRepo.getByInstanceId.mockResolvedValue(null) + // access private via any and assert thrown + await expect((svc as any).getInstanceByIdOrTag({} as any, 'i')).rejects.toHaveProperty('code', 'invalid_event') + }) + + test('getInstanceByIdOrTag returns found on fallback path', async () => { + const { svc, instanceRepo } = makeSvc() + const inst = { id: 'i', instanceId: 'i' } + instanceRepo.getById.mockRejectedValue(new Error('boom')) + instanceRepo.getByInstanceId.mockResolvedValue(inst) + const out = await (svc as any).getInstanceByIdOrTag({} as any, 'i') + expect(out.instanceId).toBe('i') + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts new file mode 100644 index 0000000000..e4a05db22f --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts @@ -0,0 +1,63 @@ +import { WorkflowService } from '../src' + +const makeSvc = (inst: any) => { + const tpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [ + { name: 'a', type: 'start' }, + { name: 'b', type: 'final' }, + ], + transitions: [{ from: 'a', to: 'b', on: 'go' }], + catalog: {}, + actions: [], + } + const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: tpl }) } as any + const instanceRepo = { + getById: async () => inst, + getByInstanceId: async () => inst, + update: async () => {}, + } as any + const config = { + guardEngine: 'jmespath', + autoReturnExistingOnSingleton: true, + actionTimeoutMs: 15000, + enableProblemReport: true, + } as any + const agentConfig = { logger: { debug() {}, info() {} } } as any + const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) + return { svc } +} + +describe('WorkflowService lifecycle gating', () => { + test('advance forbidden when paused/canceled/completed', async () => { + for (const status of ['paused', 'canceled'] as const) { + const inst = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status } + const { svc } = makeSvc(inst) + await expect(svc.advance({} as any, { instance_id: 'i', event: 'go' })).rejects.toHaveProperty( + 'code', + 'forbidden' + ) + } + // completed → invalid_event + const inst = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status: 'completed' } + const { svc } = makeSvc(inst) + await expect(svc.advance({} as any, { instance_id: 'i', event: 'go' })).rejects.toHaveProperty( + 'code', + 'invalid_event' + ) + }) + + test('complete only allowed when state is final', async () => { + const inst1 = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status: 'active' } + const { svc: s1 } = makeSvc(inst1) + await expect(s1.complete({} as any, { instance_id: 'i' })).rejects.toHaveProperty('code', 'forbidden') + + const inst2 = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'b', status: 'active' } + const { svc: s2 } = makeSvc(inst2) + const out = await s2.complete({} as any, { instance_id: 'i' }) + expect(out.status).toBe('completed') + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts new file mode 100644 index 0000000000..e8323af873 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts @@ -0,0 +1,80 @@ +import { WorkflowInstanceRecord, WorkflowService, WorkflowTemplateRecord } from '../src' + +describe('WorkflowService publishTemplate+start edge branches', () => { + test('publishTemplate updates existing record (hash + template) and returns it', async () => { + const existing = new WorkflowTemplateRecord({ + template: { + template_id: 't', + version: '1', + title: 'Old', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } as any, + }) + const templateRepo = { + findByTemplateIdAndVersion: jest.fn(async () => existing), + update: jest.fn(async () => {}), + save: jest.fn(async () => {}), + } as any + const instanceRepo = {} as any + const svc = new WorkflowService( + templateRepo, + instanceRepo, + { guardEngine: 'jmespath' } as any, + { logger: { info() {}, debug() {} } } as any + ) + const nextTpl: any = { + template_id: 't', + version: '1', + title: 'New', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + const rec = await svc.publishTemplate({} as any, nextTpl) + expect(templateRepo.update).toHaveBeenCalled() + expect(rec.template.title).toBe('New') + expect(rec.hash).toBeDefined() + }) + + test('start singleton_per_connection without autoReturnExistingOnSingleton throws already_exists', async () => { + const tpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'singleton_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + const templateRepo = { findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl })) } as any + const existing = new WorkflowInstanceRecord({ + instanceId: 'i', + templateId: 't', + templateVersion: '1', + participants: {}, + state: 's', + context: {}, + artifacts: {}, + status: 'active', + history: [], + }) + const instanceRepo = { findByTemplateAndConnection: jest.fn(async () => [existing]) } as any + const svc = new WorkflowService( + templateRepo, + instanceRepo, + { guardEngine: 'jmespath', autoReturnExistingOnSingleton: false } as any, + { logger: { info() {}, debug() {} } } as any + ) + await expect(svc.start({} as any, { template_id: 't', connection_id: 'c1' })).rejects.toHaveProperty( + 'code', + 'already_exists' + ) + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.status-options.spec.ts b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts new file mode 100644 index 0000000000..7ee15fea8e --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts @@ -0,0 +1,57 @@ +import { WorkflowService } from '../src' + +describe('WorkflowService.status include flags', () => { + const make = () => { + const tpl: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + display_hints: { + states: { + a: [ + { type: 'text', text: 'Hello' }, + { type: 'button', label: 'Go', event: 'go' }, + { type: 'submit-button', label: 'Send', event: 'send' }, + ], + }, + }, + catalog: {}, + actions: [], + } + const templateRepo = { findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl })) } as any + const inst: any = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + section: 'S', + context: {}, + artifacts: {}, + history: [], + status: 'active', + } + const instanceRepo = { getById: jest.fn(async () => inst), update: jest.fn() } as any + const config = { guardEngine: 'jmespath', enableProblemReport: true } as any + const agentConfig = { logger: { debug() {}, info() {} } } as any + const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) + return { svc } + } + + test('include_actions=false include_ui=true', async () => { + const { svc } = make() + const r = await svc.status({} as any, { instance_id: 'i', include_actions: false, include_ui: true }) + expect(r.action_menu).toEqual([]) + expect(Array.isArray(r.ui)).toBe(true) + }) + + test('include_actions=true include_ui=false', async () => { + const { svc } = make() + const r = await svc.status({} as any, { instance_id: 'i', include_actions: true, include_ui: false }) + expect(r.action_menu.map((i) => i.event)).toEqual(expect.arrayContaining(['go', 'send'])) + expect((r as any).ui).toBeUndefined() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts new file mode 100644 index 0000000000..0b7f9c8661 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts @@ -0,0 +1,87 @@ +import { WorkflowService, validateTemplateJson } from '../src' + +describe('WorkflowService.validateTemplate (private)', () => { + const make = () => { + const tplRepo = {} as any + const instRepo = {} as any + const config = { guardEngine: 'jmespath', enableProblemReport: true } as any + const agentConfig = { logger: { debug() {}, info() {} } } as any + const svc = new WorkflowService(tplRepo, instRepo, config, agentConfig) + return svc as any + } + + test('valid template passes', () => { + const svc = make() + const tpl = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + sections: [{ name: 'Main' }], + states: [{ name: 's', type: 'start', section: 'Main' }], + transitions: [{ from: 's', to: 's', on: 'x' }], + catalog: { + credential_profiles: { + test: { cred_def_id: 'C', attribute_plan: { name: { source: 'static', value: 'Alice' } }, to_ref: 'holder' }, + }, + proof_profiles: { p: { requested_attributes: ['name'], requested_predicates: [], to_ref: 'holder' } }, + }, + actions: [ + { key: 'a', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.test' }, + { key: 'b', typeURI: 'https://didcomm.org/present-proof/2.0/request-presentation', profile_ref: 'pp.p' }, + ], + } + expect(() => svc.validateTemplate(tpl)).not.toThrow() + }) + + test('invalid transitions and actions throw', () => { + const svc = make() + const bad1 = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [{ from: 'unknown', to: 's', on: 'x' }], + catalog: {}, + actions: [], + } + expect(() => svc.validateTemplate(bad1)).toThrow() + const bad2 = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [{ from: 's', to: 's', on: 'x', action: 'missing' }], + catalog: {}, + actions: [], + } + expect(() => svc.validateTemplate(bad2)).toThrow() + const bad3 = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [{ key: 'a', typeURI: 'x', profile_ref: 'zz.test' }], + } + expect(() => svc.validateTemplate(bad3)).toThrow() + }) + + test('validateTemplateJson rejects actions missing key', () => { + const bad: any = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 's', type: 'start' }], + transitions: [], + catalog: {}, + actions: [{ typeURI: 'x' }], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) +}) diff --git a/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts new file mode 100644 index 0000000000..eebfe4c7ed --- /dev/null +++ b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts @@ -0,0 +1,16 @@ +import { WorkflowTemplateRepository } from '../src' + +describe('WorkflowTemplateRepository findByTemplateIdAndVersion', () => { + test('chooses highest version when no version specified', async () => { + const repo = new WorkflowTemplateRepository({} as any, { on: () => {} } as any) + const list = [ + { template: { template_id: 't', version: '1.0.0' } }, + { template: { template_id: 't', version: '1.2.0' } }, + { template: { template_id: 't', version: '1.10.0' } }, + ] as any + jest.spyOn(repo as any, 'findByQuery').mockResolvedValue(list) + const res = await repo.findByTemplateIdAndVersion({} as any, 't') + // Sorting is lexicographic in repository implementation + expect(res?.template.version).toBe('1.2.0') + }) +}) diff --git a/packages/workflow/src/tests/setup.ts b/packages/workflow/src/tests/setup.ts new file mode 100644 index 0000000000..78143033f2 --- /dev/null +++ b/packages/workflow/src/tests/setup.ts @@ -0,0 +1,3 @@ +import 'reflect-metadata' + +jest.setTimeout(120000) From 93e1e538063bb56ac2827929df747cc1d396494e Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:37:09 -0400 Subject: [PATCH 10/20] test(e2e): add workflow anoncreds end-to-end test Add E2E test exercising anoncreds flow using the new workflow module. Signed-off-by: Vinay Singh --- tests/workflow-anoncreds.e2e.test.ts | 206 +++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/workflow-anoncreds.e2e.test.ts diff --git a/tests/workflow-anoncreds.e2e.test.ts b/tests/workflow-anoncreds.e2e.test.ts new file mode 100644 index 0000000000..1c92191610 --- /dev/null +++ b/tests/workflow-anoncreds.e2e.test.ts @@ -0,0 +1,206 @@ +import type { SubjectMessage } from './transport/SubjectInboundTransport' + +import { Subject } from 'rxjs' + +import { getAnonCredsModules } from '../packages/anoncreds/tests/anoncredsSetup' +import { getAgentOptions, makeConnection } from '../packages/core/tests/helpers' +import { anoncredsDefinitionFourAttributesNoRevocation, storePreCreatedAnonCredsDefinition } from '../packages/anoncreds/tests/preCreatedAnonCredsDefinition' +import { SubjectInboundTransport } from './transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from './transport/SubjectOutboundTransport' + +import { Agent } from '@credo-ts/core' +import { DidCommAutoAcceptCredential, DidCommMessageSender, DidCommOutboundMessageContext, DidCommCredentialsApi, DidCommCredentialState } from '@credo-ts/didcomm' +import { WorkflowModule } from '../packages/workflow/src' +import { AdvanceMessage } from '../packages/workflow/src' + +describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { + let issuerAgent: Agent + let holderAgent: Agent + + beforeEach(async () => { + const issuerOptions = getAgentOptions( + 'E2E Workflow Issuer', + { endpoints: ['rxjs:issuer'] }, + {}, + { + ...getAnonCredsModules({ autoAcceptCredentials: DidCommAutoAcceptCredential.ContentApproved }), + workflow: new WorkflowModule({ guardEngine: 'jmespath' }), + }, + { requireDidcomm: true } + ) + const holderOptions = getAgentOptions( + 'E2E Workflow Holder', + { endpoints: ['rxjs:holder'] }, + {}, + { + ...getAnonCredsModules({ autoAcceptCredentials: DidCommAutoAcceptCredential.ContentApproved }), + workflow: new WorkflowModule({ guardEngine: 'jmespath' }), + }, + { requireDidcomm: true } + ) + + issuerAgent = new Agent(issuerOptions) + holderAgent = new Agent(holderOptions) + + const subjectIssuer = new Subject() + const subjectHolder = new Subject() + const map = { 'rxjs:issuer': subjectIssuer, 'rxjs:holder': subjectHolder } + + issuerAgent.modules.didcomm.registerOutboundTransport(new SubjectOutboundTransport(map)) + issuerAgent.modules.didcomm.registerInboundTransport(new SubjectInboundTransport(subjectIssuer)) + holderAgent.modules.didcomm.registerOutboundTransport(new SubjectOutboundTransport(map)) + holderAgent.modules.didcomm.registerInboundTransport(new SubjectInboundTransport(subjectHolder)) + + await issuerAgent.initialize() + await holderAgent.initialize() + }) + + afterEach(async () => { + await issuerAgent.shutdown() + await holderAgent.shutdown() + }) + + test('Full UI flow with multi-input save and holder confirmation → credential includes submitted values', async () => { + // Connect issuer and holder + const [issuerConn, holderConn] = await makeConnection(issuerAgent, holderAgent) + expect(issuerConn).toBeConnectedWith(holderConn) + + // Prepare pre-created anoncreds def + const { credentialDefinitionId } = await storePreCreatedAnonCredsDefinition( + issuerAgent as any, + anoncredsDefinitionFourAttributesNoRevocation + ) + + // Publish template on issuer + const ui = [ + { type: 'text', text: 'Enter your details' }, + { type: 'image', url: 'https://example.com/banner.png' }, + { type: 'video', url: 'https://example.com/intro.mp4' }, + { type: 'input', name: 'name', inputType: 'text', label: 'Full Name' }, + { type: 'input', name: 'age', inputType: 'number', label: 'Age' }, + { type: 'check-box', name: 'agree', label: 'I agree' }, + { type: 'drop-down', name: 'country', options: ['US', 'CA'] }, + { type: 'button', label: 'Save', event: 'save' }, + { type: 'submit-button', label: 'Confirm', event: 'request_confirm' }, + ] + const tpl = { + template_id: 'ui-flow', + version: '1.0.0', + title: 'Workflow UI and Issue', + instance_policy: { mode: 'singleton_per_connection' }, + sections: [{ name: 'Main' }], + states: [ + { name: 'menu', type: 'start', section: 'Main' }, + { name: 'confirm', type: 'normal', section: 'Main' }, + { name: 'done', type: 'final', section: 'Main' }, + ], + transitions: [ + { from: 'menu', to: 'menu', on: 'save', action: 'state_save_form' }, + { from: 'menu', to: 'confirm', on: 'request_confirm', guard: 'context.name && context.age && context.agree && context.country' }, + { from: 'confirm', to: 'done', on: 'confirm_accept', action: 'offer_name_cred' }, + ], + catalog: { + credential_profiles: { + demo: { + cred_def_id: credentialDefinitionId, + attribute_plan: { + name: { source: 'context', path: 'name', required: true }, + age: { source: 'context', path: 'age', required: true }, + 'x-ray': { source: 'static', value: 'not taken' }, + profile_picture: { source: 'static', value: 'looking good' }, + }, + to_ref: 'holder', + }, + }, + }, + actions: [ + { key: 'state_save_form', typeURI: 'https://didcomm.org/workflow/actions/state:set@1', staticInput: { merge: '{{ input.form }}' } }, + { key: 'offer_name_cred', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.demo' }, + ], + display_hints: { states: { menu: ui, confirm: [{ type: 'text', text: 'Please confirm your details.' }, { type: 'submit-button', label: 'Accept', event: 'confirm_accept' }] } }, + } + await (issuerAgent.modules as any).workflow.publishTemplate(tpl) + + // Start instance on issuer, scoped to connection and participants + const inst = await (issuerAgent.modules as any).workflow.start({ + template_id: 'ui-flow', + connection_id: issuerConn.id, + participants: { holder: { did: issuerConn.theirDid as string } }, + context: {}, + }) + + // Status returns UI and action menu + const s1 = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId, include_ui: true }) + expect(s1.state).toBe('menu') + // UI items - various types + expect(s1.ui?.find((i: any) => i.type === 'text')?.text).toContain('Enter your details') + expect(s1.ui?.find((i: any) => i.type === 'image')?.url).toContain('banner.png') + expect(s1.ui?.find((i: any) => i.type === 'video')?.url).toContain('intro.mp4') + expect(s1.ui?.find((i: any) => i.type === 'input' && i.name === 'name')?.label).toBe('Full Name') + expect(s1.ui?.find((i: any) => i.type === 'input' && i.name === 'age')?.label).toBe('Age') + expect(s1.ui?.find((i: any) => i.type === 'check-box')?.label).toBe('I agree') + expect(s1.ui?.find((i: any) => i.type === 'drop-down')?.options).toEqual(expect.arrayContaining(['US', 'CA'])) + // Buttons + expect(s1.action_menu).toEqual(expect.arrayContaining([{ label: 'Save', event: 'save' }, { label: 'Confirm', event: 'request_confirm' }])) + + // Submit fields via save event + await (issuerAgent.modules as any).workflow.advance({ instance_id: inst.instanceId, event: 'save', idempotency_key: 'k1', input: { form: { name: 'Alice', age: 30, agree: true, country: 'US' } } }) + const sAfterSave = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId, include_ui: false }) + + // Now request_confirm becomes allowed + const s2 = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) + expect(s2.allowed_events).toContain('request_confirm') + + // Issuer requests confirmation (moves to 'confirm') + await (issuerAgent.modules as any).workflow.advance({ instance_id: inst.instanceId, event: 'request_confirm', idempotency_key: 'k2' }) + const sConfirm = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) + + // Holder would confirm. For deterministic e2e, advance on issuer side + // Holder confirms by sending Advance DIDComm message to issuer + const sender = holderAgent.dependencyManager.resolve(DidCommMessageSender) + const msg = new AdvanceMessage({ thid: inst.instanceId, body: { instance_id: inst.instanceId, event: 'confirm_accept' } }) + const outbound = new DidCommOutboundMessageContext(msg as any, { agentContext: (holderAgent as any).context, connection: holderConn as any }) + await sender.sendMessage(outbound) + + // Wait for holder to receive the offer explicitly, then accept + const holderOffer = await waitForHolderOffer(holderAgent) + await (holderAgent.modules as any).credentials.acceptOffer({ credentialExchangeRecordId: holderOffer.id, autoAcceptCredential: 2 }) + // Wait until both sides have Done + await waitForCredentialDone(issuerAgent) + await waitForCredentialDone(holderAgent) + + // Verify offer attributes contained the submitted values + const statusAfter = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) + const issueRecordId: string = (statusAfter as any).artifacts?.issueRecordId + expect(issueRecordId).toBeTruthy() + const creds = issuerAgent.dependencyManager.resolve(DidCommCredentialsApi) + const fmt = await creds.getFormatData(issueRecordId) + const nameAttr = fmt.offerAttributes?.find((a: any) => a.name === 'name') + const ageAttr = fmt.offerAttributes?.find((a: any) => a.name === 'age') + expect(nameAttr?.value).toBe('Alice') + expect(ageAttr?.value).toBe('30') + }) +}) + +async function waitForCredentialDone(agent: Agent, { timeoutMs = 10000, intervalMs = 250 } = {}) { + const start = Date.now() + const creds = agent.dependencyManager.resolve(DidCommCredentialsApi) + while (Date.now() - start < timeoutMs) { + const all = await creds.getAll() + if (all.some((r) => (r as any).state === DidCommCredentialState.Done)) return + await new Promise((r) => setTimeout(r, intervalMs)) + } + throw new Error('Timeout waiting for credential to reach Done state') +} + +async function waitForHolderOffer(agent: Agent, { timeoutMs = 10000, intervalMs = 250 } = {}) { + const start = Date.now() + const creds = agent.dependencyManager.resolve(DidCommCredentialsApi) + while (Date.now() - start < timeoutMs) { + const all = await creds.getAll() + const rec = all.find((r: any) => r.state === 'offer-received') + if (rec) return rec + await new Promise((r) => setTimeout(r, intervalMs)) + } + throw new Error('Timeout waiting for holder offer') +} From 6d9c26e5cfd76566707764e8d8a368df3ee83eca Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 02:37:19 -0400 Subject: [PATCH 11/20] chore: update pnpm-lock.yaml Update lockfile to capture workflow package dependencies. Signed-off-by: Vinay Singh --- pnpm-lock.yaml | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a875ba3f2..a5c1fa27c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1069,6 +1069,46 @@ importers: specifier: ~5.5.2 version: 5.5.4 + packages/workflow: + dependencies: + '@credo-ts/core': + specifier: workspace:* + version: link:../core + '@credo-ts/didcomm': + specifier: workspace:* + version: link:../didcomm + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + jmespath: + specifier: ^0.16.0 + version: 0.16.0 + devDependencies: + '@credo-ts/askar': + specifier: workspace:* + version: link:../askar + '@credo-ts/node': + specifier: workspace:* + version: link:../node + '@openwallet-foundation/askar-nodejs': + specifier: 'catalog:' + version: 0.4.0 + '@types/jmespath': + specifier: ^0.15.2 + version: 0.15.2 + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 5.8.3 + samples/extension-module: dependencies: '@credo-ts/askar': @@ -3493,6 +3533,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/jmespath@0.15.2': + resolution: {integrity: sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==} + '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} @@ -6069,6 +6112,10 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + joi@17.13.1: resolution: {integrity: sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==} @@ -12158,6 +12205,8 @@ snapshots: expect: 30.0.3 pretty-format: 30.0.2 + '@types/jmespath@0.15.2': {} + '@types/keygrip@1.0.6': {} '@types/koa-compose@3.2.8': @@ -15272,6 +15321,8 @@ snapshots: jimp-compact@0.16.1: {} + jmespath@0.16.0: {} + joi@17.13.1: dependencies: '@hapi/hoek': 9.3.0 From aa0585618a5d797fffa7a4dce3c5437b8215279b Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 04:07:14 -0400 Subject: [PATCH 12/20] chore(workflow): force guard evaluation to JMESPath Remove JS eval fallback in GuardEvaluator and ignore configurable guard engine in service. This enforces side-effect-free guard evaluation. Signed-off-by: Vinay Singh --- .../workflow/src/engine/GuardEvaluator.ts | 21 +++++-------------- .../workflow/src/services/WorkflowService.ts | 6 +++--- .../src/tests/Engine.utilities.spec.ts | 12 +++-------- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/packages/workflow/src/engine/GuardEvaluator.ts b/packages/workflow/src/engine/GuardEvaluator.ts index 6deab3c8bd..e0a10a8bbc 100644 --- a/packages/workflow/src/engine/GuardEvaluator.ts +++ b/packages/workflow/src/engine/GuardEvaluator.ts @@ -8,30 +8,19 @@ export type GuardEnv = { } export class GuardEvaluator { - public static evalGuard( - expression: string | undefined, - env: GuardEnv, - engine: 'jmespath' | 'cel' | 'js' = 'jmespath' - ): boolean { + public static evalGuard(expression: string | undefined, env: GuardEnv): boolean { if (!expression) return true try { - if (engine === 'jmespath') { - const res = jmespath.search(env as any, expression) - return !!res - } - // fallback JS for dev - const fn = new Function('context', 'participants', 'artifacts', `return (${expression});`) - return !!fn(env.context, env.participants, env.artifacts) + const res = jmespath.search(env as any, expression) + return !!res } catch { return false } } - public static evalValue(expression: string, env: GuardEnv, engine: 'jmespath' | 'cel' | 'js' = 'jmespath'): any { + public static evalValue(expression: string, env: GuardEnv): any { try { - if (engine === 'jmespath') return jmespath.search(env as any, expression) - const fn = new Function('context', 'participants', 'artifacts', `return (${expression});`) - return fn(env.context, env.participants, env.artifacts) + return jmespath.search(env as any, expression) } catch { return undefined } diff --git a/packages/workflow/src/services/WorkflowService.ts b/packages/workflow/src/services/WorkflowService.ts index f103117154..51c63bdd6f 100644 --- a/packages/workflow/src/services/WorkflowService.ts +++ b/packages/workflow/src/services/WorkflowService.ts @@ -198,7 +198,7 @@ export class WorkflowService { if (!candidates.length) throw this.problem('invalid_event', `no transition for event ${opts.event} from ${inst.state}`) const env = GuardEvaluator.envFromInstance(this.toInstanceData(inst)) - const enabled = candidates.filter((t) => GuardEvaluator.evalGuard(t.guard, env, this.config.guardEngine)) + const enabled = candidates.filter((t) => GuardEvaluator.evalGuard(t.guard, env)) if (!enabled.length) throw this.problem('guard_failed', 'guard evaluated false') const t = enabled[0] @@ -324,7 +324,7 @@ export class WorkflowService { const tpl = tplRec.template const env = GuardEvaluator.envFromInstance(this.toInstanceData(inst)) const allowed = transitionsFromState(tpl, inst.state) - .filter((t) => GuardEvaluator.evalGuard(t.guard, env, this.config.guardEngine)) + .filter((t) => GuardEvaluator.evalGuard(t.guard, env)) .map((t) => t.on) const includeActions = opts.include_actions ?? true const includeUi = opts.include_ui ?? true @@ -444,7 +444,7 @@ export class WorkflowService { private evalMultiplicity(expr: string, context: Record): string { try { const env = { context, participants: {}, artifacts: {} } - const val = GuardEvaluator.evalValue(expr, env, this.config.guardEngine) + const val = GuardEvaluator.evalValue(expr, env) return val != null ? String(val) : '' } catch { return '' diff --git a/packages/workflow/src/tests/Engine.utilities.spec.ts b/packages/workflow/src/tests/Engine.utilities.spec.ts index e8fc6e5add..6fb8cf1265 100644 --- a/packages/workflow/src/tests/Engine.utilities.spec.ts +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -20,19 +20,13 @@ describe('Engine helpers', () => { test('GuardEvaluator evalGuard with JMESPath truthy/falsey', () => { const env = { context: { a: 1, b: 0 }, participants: {}, artifacts: {} } - expect(GuardEvaluator.evalGuard('context.a', env as any, 'jmespath')).toBe(true) - expect(GuardEvaluator.evalGuard('context.b', env as any, 'jmespath')).toBe(false) + expect(GuardEvaluator.evalGuard('context.a', env as any)).toBe(true) + expect(GuardEvaluator.evalGuard('context.b', env as any)).toBe(false) }) test('GuardEvaluator evalValue returns selected JSON piece', () => { const env = { context: { a: { x: 'ok' } }, participants: {}, artifacts: {} } - expect(GuardEvaluator.evalValue('context.a.x', env as any, 'jmespath')).toBe('ok') - }) - - test('GuardEvaluator JS engine evalGuard/evalValue', () => { - const env = { context: { a: 2 }, participants: {}, artifacts: {} } - expect(GuardEvaluator.evalGuard('context.a + 1 > 2', env as any, 'js')).toBe(true) - expect(GuardEvaluator.evalValue('context.a + 2', env as any, 'js')).toBe(4) + expect(GuardEvaluator.evalValue('context.a.x', env as any)).toBe('ok') }) test('AttributePlanner compute error returns undefined unless required', () => { From 044a51507a8b460b86e728eb82690cfdfe8dca42 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 04:12:50 -0400 Subject: [PATCH 13/20] feat(workflow): compute expressions use JMESPath Remove JS-based compute evaluator and run compute over a pure JMESPath environment with {context, participants, artifacts, now}. Updates tests to use join() instead of concat(). Signed-off-by: Vinay Singh --- .../workflow/src/engine/AttributePlanner.ts | 19 +++++++++++-------- .../src/tests/Engine.utilities.spec.ts | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/workflow/src/engine/AttributePlanner.ts b/packages/workflow/src/engine/AttributePlanner.ts index 31b0838748..bc1dfcc265 100644 --- a/packages/workflow/src/engine/AttributePlanner.ts +++ b/packages/workflow/src/engine/AttributePlanner.ts @@ -1,3 +1,4 @@ +import jmespath from 'jmespath' import { AttributeSpec, WorkflowInstanceData } from '../model/types' const isObject = (v: any) => v && typeof v === 'object' && !Array.isArray(v) @@ -13,7 +14,7 @@ export class AttributePlanner { } else if ((spec as any).source === 'static') { value = (spec as any).value } else if ((spec as any).source === 'compute') { - value = AttributePlanner.computeExpr((spec as any).expr) + value = AttributePlanner.computeExpr((spec as any).expr, instance) } if ((spec as any).required && (value === undefined || value === null || value === '')) { throw Object.assign(new Error('missing_attributes'), { code: 'missing_attributes', attribute: key }) @@ -27,15 +28,17 @@ export class AttributePlanner { return path.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), obj) } - private static computeExpr(expr: string): any { - // Limited helpers: now(), concat(a,b,...) - const helpers = { - now: () => new Date().toISOString(), - concat: (...args: any[]) => args.map((a) => (a == null ? '' : String(a))).join(''), + private static computeExpr(expr: string, instance: WorkflowInstanceData): any { + // Evaluate compute expressions using JMESPath over a pure env + // Expose a stable 'now' value as an ISO string for this evaluation + const env = { + context: instance.context || {}, + participants: instance.participants || {}, + artifacts: instance.artifacts || {}, + now: new Date().toISOString(), } try { - const fn = new Function('now', 'concat', `return ((${expr}));`) - return fn(helpers.now, helpers.concat) + return jmespath.search(env as any, expr) } catch { return undefined } diff --git a/packages/workflow/src/tests/Engine.utilities.spec.ts b/packages/workflow/src/tests/Engine.utilities.spec.ts index 6fb8cf1265..f08da9828e 100644 --- a/packages/workflow/src/tests/Engine.utilities.spec.ts +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -5,7 +5,7 @@ describe('Engine helpers', () => { const plan: any = { a: { source: 'context', path: 'user.name', required: true }, b: { source: 'static', value: 42 }, - c: { source: 'compute', expr: 'concat("hi-", "there")' }, + c: { source: 'compute', expr: "join('', ['hi-','there'])" }, } const instance: any = { context: { user: { name: 'Alice' } } } const out = AttributePlanner.materialize(plan, instance) From e8da346f2304e41ed92c6907fc7d32e6a1c6502a Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 04:28:34 -0400 Subject: [PATCH 14/20] test(workflow): fix test imports for src/tests layout Replace '../src' with '..' in package tests so ts-jest resolves package exports correctly. Signed-off-by: Vinay Singh --- .../src/tests/ActionHandlers.message-id-resolution.spec.ts | 2 +- packages/workflow/src/tests/Engine.utilities.spec.ts | 2 +- packages/workflow/src/tests/IssueCredentialV2Action.spec.ts | 2 +- .../tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts | 2 +- packages/workflow/src/tests/LocalStateSetAction.spec.ts | 2 +- packages/workflow/src/tests/PresentProofV2Action.spec.ts | 2 +- packages/workflow/src/tests/ProblemReportHandler.spec.ts | 2 +- .../workflow/src/tests/ProtocolHandlers.additional.spec.ts | 2 +- .../tests/ProtocolHandlers.problem-report-controls.spec.ts | 2 +- .../src/tests/ProtocolHandlers.problem-report-mapping.spec.ts | 2 +- .../src/tests/ProtocolHandlers.status-responses.spec.ts | 2 +- .../workflow/src/tests/ProtocolMessages.constructors.spec.ts | 2 +- .../workflow/src/tests/PublishTemplateHandler.success.spec.ts | 2 +- packages/workflow/src/tests/StartHandler.success.spec.ts | 2 +- .../workflow/src/tests/Status.ui-payload.integration.spec.ts | 2 +- packages/workflow/src/tests/TemplateRefs.valid.spec.ts | 2 +- packages/workflow/src/tests/TemplateSchema.additional.spec.ts | 2 +- packages/workflow/src/tests/TemplateSchema.branches.spec.ts | 2 +- packages/workflow/src/tests/TemplateSchema.invalid.spec.ts | 2 +- .../src/tests/TemplateValidation.additional-coverage.spec.ts | 2 +- .../workflow/src/tests/TemplateValidation.invalid.spec.ts | 2 +- packages/workflow/src/tests/WorkflowApi.spec.ts | 2 +- .../WorkflowInstanceRepository.queries.additional.spec.ts | 2 +- .../workflow/src/tests/WorkflowInstanceRepository.spec.ts | 2 +- .../src/tests/WorkflowModule.inbound-events.extended.spec.ts | 2 +- .../workflow/src/tests/WorkflowModule.inbound-events.spec.ts | 2 +- .../workflow/src/tests/WorkflowModule.integration.spec.ts | 2 +- .../workflow/src/tests/WorkflowService.complete-send.spec.ts | 2 +- .../tests/WorkflowService.concurrency-and-conflicts.spec.ts | 2 +- .../workflow/src/tests/WorkflowService.edge-cases.spec.ts | 4 ++-- packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts | 2 +- .../src/tests/WorkflowService.publish-and-start.spec.ts | 2 +- .../workflow/src/tests/WorkflowService.status-options.spec.ts | 2 +- .../src/tests/WorkflowService.template-validation.spec.ts | 2 +- .../workflow/src/tests/WorkflowTemplateRepository.spec.ts | 2 +- 35 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts index c54f45eb1e..f1c3920a8b 100644 --- a/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts +++ b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts @@ -1,4 +1,4 @@ -import { IssueCredentialV2Action, PresentProofV2Action } from '../src' +import { IssueCredentialV2Action, PresentProofV2Action } from '..' const makeAgentContext = (mocks: any) => ({ dependencyManager: { diff --git a/packages/workflow/src/tests/Engine.utilities.spec.ts b/packages/workflow/src/tests/Engine.utilities.spec.ts index f08da9828e..5f266c8e12 100644 --- a/packages/workflow/src/tests/Engine.utilities.spec.ts +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -1,4 +1,4 @@ -import { AttributePlanner, GuardEvaluator } from '../src' +import { AttributePlanner, GuardEvaluator } from '..' describe('Engine helpers', () => { test('AttributePlanner materialize: context/static/compute', () => { diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts index 580a48619b..b653c6ffee 100644 --- a/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts +++ b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts @@ -1,4 +1,4 @@ -import { IssueCredentialV2Action } from '../src' +import { IssueCredentialV2Action } from '..' describe('IssueCredentialV2Action', () => { test('missing profile and connection errors, and wraps thrown errors', async () => { diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts index f3b8f8a140..36673bc6ac 100644 --- a/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts +++ b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts @@ -1,5 +1,5 @@ import 'reflect-metadata' -import { IssueCredentialV2Action } from '../src' +import { IssueCredentialV2Action } from '..' describe('to_ref recipient DID enforcement', () => { test('forbidden when theirDid mismatches participants[to_ref].did', async () => { diff --git a/packages/workflow/src/tests/LocalStateSetAction.spec.ts b/packages/workflow/src/tests/LocalStateSetAction.spec.ts index 82e1f28574..acea29fc63 100644 --- a/packages/workflow/src/tests/LocalStateSetAction.spec.ts +++ b/packages/workflow/src/tests/LocalStateSetAction.spec.ts @@ -1,4 +1,4 @@ -import { LocalStateSetAction } from '../src' +import { LocalStateSetAction } from '..' describe('LocalStateSetAction', () => { test('merges static object', async () => { diff --git a/packages/workflow/src/tests/PresentProofV2Action.spec.ts b/packages/workflow/src/tests/PresentProofV2Action.spec.ts index 969b892718..349e22e106 100644 --- a/packages/workflow/src/tests/PresentProofV2Action.spec.ts +++ b/packages/workflow/src/tests/PresentProofV2Action.spec.ts @@ -1,4 +1,4 @@ -import { PresentProofV2Action } from '../src' +import { PresentProofV2Action } from '..' describe('PresentProofV2Action', () => { test('builds predicates and wraps errors', async () => { diff --git a/packages/workflow/src/tests/ProblemReportHandler.spec.ts b/packages/workflow/src/tests/ProblemReportHandler.spec.ts index 720a56d119..dc9f298dcd 100644 --- a/packages/workflow/src/tests/ProblemReportHandler.spec.ts +++ b/packages/workflow/src/tests/ProblemReportHandler.spec.ts @@ -1,4 +1,4 @@ -import { ProblemReportHandler, ProblemReportMessage } from '../src' +import { ProblemReportHandler, ProblemReportMessage } from '..' describe('ProblemReportHandler', () => { test('logs and returns undefined', async () => { diff --git a/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts index 76891ef289..e06e609567 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts @@ -7,7 +7,7 @@ import { StatusHandler, StatusRequestMessage, WorkflowModuleConfig, -} from '../src' +} from '..' const makeAgentContext = () => ({ dependencyManager: { diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts index db259e040d..f1c9f1b4af 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts @@ -1,4 +1,4 @@ -import { CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '../src' +import { CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '..' const makeCtx = () => ({ dependencyManager: { diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts index 41b9c285a8..a606e555f1 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts @@ -6,7 +6,7 @@ import { StatusHandler, StatusRequestMessage, WorkflowModuleConfig, -} from '../src' +} from '..' const makeAgentContext = () => ({ dependencyManager: { diff --git a/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts index 134c5238f4..16f6cc889e 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts @@ -5,7 +5,7 @@ import { PauseHandler, ResumeHandler, WorkflowModuleConfig, -} from '../src' +} from '..' const ctx = () => ({ dependencyManager: { diff --git a/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts index 3e7c61b2b6..e36eb43537 100644 --- a/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts +++ b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts @@ -5,7 +5,7 @@ import { PublishTemplateMessage, ResumeMessage, StatusMessage, -} from '../src' +} from '..' describe('Protocol messages constructors', () => { test('Pause/Resume/Cancel/Complete set type, body and thread', () => { diff --git a/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts index b64f0a6d0b..14996c7185 100644 --- a/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts +++ b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts @@ -1,4 +1,4 @@ -import { PublishTemplateHandler } from '../src' +import { PublishTemplateHandler } from '..' describe('PublishTemplateHandler success', () => { test('returns undefined on success (no outbound message)', async () => { diff --git a/packages/workflow/src/tests/StartHandler.success.spec.ts b/packages/workflow/src/tests/StartHandler.success.spec.ts index 6190954c2a..7e51f9a06f 100644 --- a/packages/workflow/src/tests/StartHandler.success.spec.ts +++ b/packages/workflow/src/tests/StartHandler.success.spec.ts @@ -1,4 +1,4 @@ -import { StartHandler } from '../src' +import { StartHandler } from '..' describe('StartHandler success', () => { test('returns StatusMessage on success', async () => { diff --git a/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts index 45e7b6943c..dc798d44af 100644 --- a/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts +++ b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts @@ -2,7 +2,7 @@ import { AskarModule } from '@credo-ts/askar' import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { askar } from '@openwallet-foundation/askar-nodejs' -import { WorkflowModule } from '../src' +import { WorkflowModule } from '..' const makeAgent = async () => { const agent = new Agent({ diff --git a/packages/workflow/src/tests/TemplateRefs.valid.spec.ts b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts index 06242251dc..b5f50ff073 100644 --- a/packages/workflow/src/tests/TemplateRefs.valid.spec.ts +++ b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateRefs } from '../src' +import { validateTemplateRefs } from '..' describe('validateTemplateRefs positive (cp.* and pp.*)', () => { test('cp.* and pp.* profile_ref resolve to catalog entries', () => { diff --git a/packages/workflow/src/tests/TemplateSchema.additional.spec.ts b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts index 35dd005f2a..0c8d4c8880 100644 --- a/packages/workflow/src/tests/TemplateSchema.additional.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateJson } from '../src' +import { validateTemplateJson } from '..' describe('Schemas extra invalid cases', () => { test('invalid instance_policy (missing mode)', () => { diff --git a/packages/workflow/src/tests/TemplateSchema.branches.spec.ts b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts index ef61c492f5..313b47c2c0 100644 --- a/packages/workflow/src/tests/TemplateSchema.branches.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateJson } from '../src' +import { validateTemplateJson } from '..' const base = { template_id: 't', diff --git a/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts index b86d612f84..f883d2e853 100644 --- a/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateJson } from '../src' +import { validateTemplateJson } from '..' describe('schemas.ts more invalids', () => { test('transitions missing from', () => { diff --git a/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts index 5a4a92be1b..4e8c835382 100644 --- a/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts +++ b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateJson, validateTemplateRefs } from '../src' +import { validateTemplateJson, validateTemplateRefs } from '..' describe('schemas.ts additional coverage', () => { test('transitions missing on', () => { diff --git a/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts index a437e9d885..25c65b4830 100644 --- a/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts +++ b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts @@ -1,4 +1,4 @@ -import { validateTemplateJson, validateTemplateRefs } from '../src' +import { validateTemplateJson, validateTemplateRefs } from '..' const baseTpl = { template_id: 't', diff --git a/packages/workflow/src/tests/WorkflowApi.spec.ts b/packages/workflow/src/tests/WorkflowApi.spec.ts index b1e42757ba..0e22354eec 100644 --- a/packages/workflow/src/tests/WorkflowApi.spec.ts +++ b/packages/workflow/src/tests/WorkflowApi.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowApi } from '../src' +import { WorkflowApi } from '..' describe('WorkflowApi pass-through', () => { const make = () => { diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts index 2ee8537aea..7638fb81c3 100644 --- a/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowInstanceRepository } from '../src' +import { WorkflowInstanceRepository } from '..' describe('WorkflowInstanceRepository filters', () => { test('findByTemplateConnAndMultiplicity passes all filters', async () => { diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts index 75528c98e4..861dff33a8 100644 --- a/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowInstanceRepository } from '../src' +import { WorkflowInstanceRepository } from '..' describe('WorkflowInstanceRepository helpers', () => { test('findLatestByConnection returns most recent by updatedAt/createdAt', async () => { diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts index da7fcd893d..9aa65d772f 100644 --- a/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts @@ -4,7 +4,7 @@ import { DidCommProofEventTypes, DidCommProofState, } from '@credo-ts/didcomm' -import { WorkflowModule } from '../src' +import { WorkflowModule } from '..' describe('WorkflowModule event mapping (Done branches)', () => { test('maps Done branches for credentials and proofs', async () => { diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts index 852dadcc2f..4c88640f9e 100644 --- a/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts @@ -4,7 +4,7 @@ import { DidCommProofEventTypes, DidCommProofState, } from '@credo-ts/didcomm' -import { WorkflowModule } from '../src' +import { WorkflowModule } from '..' describe('WorkflowModule event mapping', () => { test('maps credential/proof events to workflow autoAdvance', async () => { diff --git a/packages/workflow/src/tests/WorkflowModule.integration.spec.ts b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts index 0bbb9fbb6b..0ddff7f421 100644 --- a/packages/workflow/src/tests/WorkflowModule.integration.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts @@ -2,7 +2,7 @@ import { AskarModule } from '@credo-ts/askar' import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { askar } from '@openwallet-foundation/askar-nodejs' -import { WorkflowModule } from '../src' +import { WorkflowModule } from '..' const makeAgent = async () => { const agent = new Agent({ diff --git a/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts index 4c7e5a9324..c625e45fcc 100644 --- a/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService } from '../src' +import { WorkflowService } from '..' const baseTpl: any = { template_id: 't', diff --git a/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts index 32b0e5a231..b936a70978 100644 --- a/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService } from '../src' +import { WorkflowService } from '..' describe('WorkflowService concurrency conflict', () => { test('advance throws state_conflict when state changed concurrently', async () => { diff --git a/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts index 9043f2b770..8469e2f4f2 100644 --- a/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService, WorkflowTemplateRecord } from '../src' +import { WorkflowService, WorkflowTemplateRecord } from '..' describe('WorkflowService additional coverage', () => { const makeSvc = (overrides: any = {}) => { @@ -100,7 +100,7 @@ describe('WorkflowService additional coverage', () => { test('start with invalid JS multiplicity_key results in empty multiplicityKeyValue', async () => { const { svc, instanceRepo } = makeSvc({ multiplicity_key: 'context.k', engine: 'js' }) // Spy on GuardEvaluator.evalValue to throw to hit evalMultiplicity catch - const Guard = require('../src').GuardEvaluator + const Guard = require('..').GuardEvaluator jest.spyOn(Guard, 'evalValue').mockImplementation(() => { throw new Error('bang') }) diff --git a/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts index e4a05db22f..468bac9fef 100644 --- a/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService } from '../src' +import { WorkflowService } from '..' const makeSvc = (inst: any) => { const tpl: any = { diff --git a/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts index e8323af873..b27e26ef21 100644 --- a/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowInstanceRecord, WorkflowService, WorkflowTemplateRecord } from '../src' +import { WorkflowInstanceRecord, WorkflowService, WorkflowTemplateRecord } from '..' describe('WorkflowService publishTemplate+start edge branches', () => { test('publishTemplate updates existing record (hash + template) and returns it', async () => { diff --git a/packages/workflow/src/tests/WorkflowService.status-options.spec.ts b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts index 7ee15fea8e..dc96921fdb 100644 --- a/packages/workflow/src/tests/WorkflowService.status-options.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService } from '../src' +import { WorkflowService } from '..' describe('WorkflowService.status include flags', () => { const make = () => { diff --git a/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts index 0b7f9c8661..7bcff89872 100644 --- a/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowService, validateTemplateJson } from '../src' +import { WorkflowService, validateTemplateJson } from '..' describe('WorkflowService.validateTemplate (private)', () => { const make = () => { diff --git a/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts index eebfe4c7ed..6fd6582434 100644 --- a/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts +++ b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowTemplateRepository } from '../src' +import { WorkflowTemplateRepository } from '..' describe('WorkflowTemplateRepository findByTemplateIdAndVersion', () => { test('chooses highest version when no version specified', async () => { From 75ef9e0334697a1d7681df3abf6c37f565e8e738 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 10:38:19 -0400 Subject: [PATCH 15/20] test(e2e): refine workflow anoncreds test types and assertions - Use precise types for UI checks and message context; format long assertions. Signed-off-by: Vinay Singh --- tests/workflow-anoncreds.e2e.test.ts | 129 +++++++++++++++++++-------- 1 file changed, 92 insertions(+), 37 deletions(-) diff --git a/tests/workflow-anoncreds.e2e.test.ts b/tests/workflow-anoncreds.e2e.test.ts index 1c92191610..6cb9dd1edd 100644 --- a/tests/workflow-anoncreds.e2e.test.ts +++ b/tests/workflow-anoncreds.e2e.test.ts @@ -3,15 +3,26 @@ import type { SubjectMessage } from './transport/SubjectInboundTransport' import { Subject } from 'rxjs' import { getAnonCredsModules } from '../packages/anoncreds/tests/anoncredsSetup' +import { + anoncredsDefinitionFourAttributesNoRevocation, + storePreCreatedAnonCredsDefinition, +} from '../packages/anoncreds/tests/preCreatedAnonCredsDefinition' import { getAgentOptions, makeConnection } from '../packages/core/tests/helpers' -import { anoncredsDefinitionFourAttributesNoRevocation, storePreCreatedAnonCredsDefinition } from '../packages/anoncreds/tests/preCreatedAnonCredsDefinition' import { SubjectInboundTransport } from './transport/SubjectInboundTransport' import { SubjectOutboundTransport } from './transport/SubjectOutboundTransport' import { Agent } from '@credo-ts/core' -import { DidCommAutoAcceptCredential, DidCommMessageSender, DidCommOutboundMessageContext, DidCommCredentialsApi, DidCommCredentialState } from '@credo-ts/didcomm' +import { + DidCommAutoAcceptCredential, + DidCommCredentialState, + DidCommCredentialsApi, + DidCommMessageSender, + DidCommOutboundMessageContext, +} from '@credo-ts/didcomm' import { WorkflowModule } from '../packages/workflow/src' import { AdvanceMessage } from '../packages/workflow/src' +import type { WorkflowApi } from '../packages/workflow/src' +import type { UiItem } from '../packages/workflow/src/model/types' describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { let issuerAgent: Agent @@ -67,7 +78,7 @@ describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { // Prepare pre-created anoncreds def const { credentialDefinitionId } = await storePreCreatedAnonCredsDefinition( - issuerAgent as any, + issuerAgent, anoncredsDefinitionFourAttributesNoRevocation ) @@ -83,20 +94,25 @@ describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { { type: 'button', label: 'Save', event: 'save' }, { type: 'submit-button', label: 'Confirm', event: 'request_confirm' }, ] - const tpl = { + const tpl: import('../packages/workflow/src').WorkflowTemplate = { template_id: 'ui-flow', version: '1.0.0', title: 'Workflow UI and Issue', - instance_policy: { mode: 'singleton_per_connection' }, + instance_policy: { mode: 'singleton_per_connection' as const }, sections: [{ name: 'Main' }], states: [ - { name: 'menu', type: 'start', section: 'Main' }, - { name: 'confirm', type: 'normal', section: 'Main' }, - { name: 'done', type: 'final', section: 'Main' }, + { name: 'menu', type: 'start' as const, section: 'Main' }, + { name: 'confirm', type: 'normal' as const, section: 'Main' }, + { name: 'done', type: 'final' as const, section: 'Main' }, ], transitions: [ { from: 'menu', to: 'menu', on: 'save', action: 'state_save_form' }, - { from: 'menu', to: 'confirm', on: 'request_confirm', guard: 'context.name && context.age && context.agree && context.country' }, + { + from: 'menu', + to: 'confirm', + on: 'request_confirm', + guard: 'context.name && context.age && context.agree && context.country', + }, { from: 'confirm', to: 'done', on: 'confirm_accept', action: 'offer_name_cred' }, ], catalog: { @@ -114,15 +130,32 @@ describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { }, }, actions: [ - { key: 'state_save_form', typeURI: 'https://didcomm.org/workflow/actions/state:set@1', staticInput: { merge: '{{ input.form }}' } }, - { key: 'offer_name_cred', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.demo' }, + { + key: 'state_save_form', + typeURI: 'https://didcomm.org/workflow/actions/state:set@1', + staticInput: { merge: '{{ input.form }}' }, + }, + { + key: 'offer_name_cred', + typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', + profile_ref: 'cp.demo', + }, ], - display_hints: { states: { menu: ui, confirm: [{ type: 'text', text: 'Please confirm your details.' }, { type: 'submit-button', label: 'Accept', event: 'confirm_accept' }] } }, + display_hints: { + states: { + menu: ui, + confirm: [ + { type: 'text', text: 'Please confirm your details.' }, + { type: 'submit-button', label: 'Accept', event: 'confirm_accept' }, + ], + }, + }, } - await (issuerAgent.modules as any).workflow.publishTemplate(tpl) + const issuerWorkflow = (issuerAgent.modules as unknown as { workflow: WorkflowApi }).workflow + await issuerWorkflow.publishTemplate(tpl) // Start instance on issuer, scoped to connection and participants - const inst = await (issuerAgent.modules as any).workflow.start({ + const inst = await issuerWorkflow.start({ template_id: 'ui-flow', connection_id: issuerConn.id, participants: { holder: { did: issuerConn.theirDid as string } }, @@ -130,53 +163,75 @@ describe('Workflow AnonCreds E2E with UI and holder confirmation', () => { }) // Status returns UI and action menu - const s1 = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId, include_ui: true }) + const s1 = await issuerWorkflow.status({ instance_id: inst.instanceId, include_ui: true }) expect(s1.state).toBe('menu') // UI items - various types - expect(s1.ui?.find((i: any) => i.type === 'text')?.text).toContain('Enter your details') - expect(s1.ui?.find((i: any) => i.type === 'image')?.url).toContain('banner.png') - expect(s1.ui?.find((i: any) => i.type === 'video')?.url).toContain('intro.mp4') - expect(s1.ui?.find((i: any) => i.type === 'input' && i.name === 'name')?.label).toBe('Full Name') - expect(s1.ui?.find((i: any) => i.type === 'input' && i.name === 'age')?.label).toBe('Age') - expect(s1.ui?.find((i: any) => i.type === 'check-box')?.label).toBe('I agree') - expect(s1.ui?.find((i: any) => i.type === 'drop-down')?.options).toEqual(expect.arrayContaining(['US', 'CA'])) + const ui1: UiItem[] = s1.ui ?? [] + expect(ui1.find((i: UiItem) => i.type === 'text')?.text).toContain('Enter your details') + expect(ui1.find((i: UiItem) => i.type === 'image')?.url).toContain('banner.png') + expect(ui1.find((i: UiItem) => i.type === 'video')?.url).toContain('intro.mp4') + expect(ui1.find((i: UiItem) => i.type === 'input' && i.name === 'name')?.label).toBe('Full Name') + expect(ui1.find((i: UiItem) => i.type === 'input' && i.name === 'age')?.label).toBe('Age') + expect(ui1.find((i: UiItem) => i.type === 'check-box')?.label).toBe('I agree') + expect(ui1.find((i: UiItem) => i.type === 'drop-down')?.options).toEqual(expect.arrayContaining(['US', 'CA'])) // Buttons - expect(s1.action_menu).toEqual(expect.arrayContaining([{ label: 'Save', event: 'save' }, { label: 'Confirm', event: 'request_confirm' }])) + expect(s1.action_menu).toEqual( + expect.arrayContaining([ + { label: 'Save', event: 'save' }, + { label: 'Confirm', event: 'request_confirm' }, + ]) + ) // Submit fields via save event - await (issuerAgent.modules as any).workflow.advance({ instance_id: inst.instanceId, event: 'save', idempotency_key: 'k1', input: { form: { name: 'Alice', age: 30, agree: true, country: 'US' } } }) - const sAfterSave = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId, include_ui: false }) + await issuerWorkflow.advance({ + instance_id: inst.instanceId, + event: 'save', + idempotency_key: 'k1', + input: { form: { name: 'Alice', age: 30, agree: true, country: 'US' } }, + }) + const _sAfterSave = await issuerWorkflow.status({ instance_id: inst.instanceId, include_ui: false }) // Now request_confirm becomes allowed - const s2 = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) + const s2 = await issuerWorkflow.status({ instance_id: inst.instanceId }) expect(s2.allowed_events).toContain('request_confirm') // Issuer requests confirmation (moves to 'confirm') - await (issuerAgent.modules as any).workflow.advance({ instance_id: inst.instanceId, event: 'request_confirm', idempotency_key: 'k2' }) - const sConfirm = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) + await issuerWorkflow.advance({ instance_id: inst.instanceId, event: 'request_confirm', idempotency_key: 'k2' }) + const _sConfirm = await issuerWorkflow.status({ instance_id: inst.instanceId }) // Holder would confirm. For deterministic e2e, advance on issuer side // Holder confirms by sending Advance DIDComm message to issuer const sender = holderAgent.dependencyManager.resolve(DidCommMessageSender) - const msg = new AdvanceMessage({ thid: inst.instanceId, body: { instance_id: inst.instanceId, event: 'confirm_accept' } }) - const outbound = new DidCommOutboundMessageContext(msg as any, { agentContext: (holderAgent as any).context, connection: holderConn as any }) + const msg = new AdvanceMessage({ + thid: inst.instanceId, + body: { instance_id: inst.instanceId, event: 'confirm_accept' }, + }) + const outbound = new DidCommOutboundMessageContext(msg, { + agentContext: holderAgent.context, + connection: holderConn, + }) await sender.sendMessage(outbound) // Wait for holder to receive the offer explicitly, then accept const holderOffer = await waitForHolderOffer(holderAgent) - await (holderAgent.modules as any).credentials.acceptOffer({ credentialExchangeRecordId: holderOffer.id, autoAcceptCredential: 2 }) + const holderCreds = holderAgent.dependencyManager.resolve(DidCommCredentialsApi) + await holderCreds.acceptOffer({ credentialExchangeRecordId: holderOffer.id }) // Wait until both sides have Done await waitForCredentialDone(issuerAgent) await waitForCredentialDone(holderAgent) // Verify offer attributes contained the submitted values - const statusAfter = await (issuerAgent.modules as any).workflow.status({ instance_id: inst.instanceId }) - const issueRecordId: string = (statusAfter as any).artifacts?.issueRecordId + const statusAfter = await issuerWorkflow.status({ instance_id: inst.instanceId }) + const issueRecordId = statusAfter.artifacts?.issueRecordId as string expect(issueRecordId).toBeTruthy() const creds = issuerAgent.dependencyManager.resolve(DidCommCredentialsApi) const fmt = await creds.getFormatData(issueRecordId) - const nameAttr = fmt.offerAttributes?.find((a: any) => a.name === 'name') - const ageAttr = fmt.offerAttributes?.find((a: any) => a.name === 'age') + const nameAttr = (fmt.offerAttributes as Array<{ name: string; value: string }> | undefined)?.find( + (a) => a.name === 'name' + ) + const ageAttr = (fmt.offerAttributes as Array<{ name: string; value: string }> | undefined)?.find( + (a) => a.name === 'age' + ) expect(nameAttr?.value).toBe('Alice') expect(ageAttr?.value).toBe('30') }) @@ -187,7 +242,7 @@ async function waitForCredentialDone(agent: Agent, { timeoutMs = 10000, interval const creds = agent.dependencyManager.resolve(DidCommCredentialsApi) while (Date.now() - start < timeoutMs) { const all = await creds.getAll() - if (all.some((r) => (r as any).state === DidCommCredentialState.Done)) return + if (all.some((r) => (r.state as unknown as string) === DidCommCredentialState.Done)) return await new Promise((r) => setTimeout(r, intervalMs)) } throw new Error('Timeout waiting for credential to reach Done state') @@ -198,7 +253,7 @@ async function waitForHolderOffer(agent: Agent, { timeoutMs = 10000, intervalMs const creds = agent.dependencyManager.resolve(DidCommCredentialsApi) while (Date.now() - start < timeoutMs) { const all = await creds.getAll() - const rec = all.find((r: any) => r.state === 'offer-received') + const rec = all.find((r) => (r.state as unknown as string) === 'offer-received') if (rec) return rec await new Promise((r) => setTimeout(r, intervalMs)) } From fd2b14aef341f47fda4696e9587dc25986df5f6d Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 10:38:59 -0400 Subject: [PATCH 16/20] test(workflow): type-safe mocks, listener callbacks, formatting - Remove explicit any and tighten mock signatures. - Resolve ctor names safely; accept payload args in listener mocks. - Import ordering and formatting aligned with Biome. Signed-off-by: Vinay Singh --- ...tionHandlers.message-id-resolution.spec.ts | 51 ++++--- .../src/tests/Engine.utilities.spec.ts | 24 ++-- .../src/tests/IssueCredentialV2Action.spec.ts | 40 ++++-- ...dentialV2Action.to-ref-enforcement.spec.ts | 23 +-- .../src/tests/LocalStateSetAction.spec.ts | 24 +++- .../src/tests/PresentProofV2Action.spec.ts | 34 ++--- .../src/tests/ProblemReportHandler.spec.ts | 4 +- .../tests/ProtocolHandlers.additional.spec.ts | 49 ++++--- ...olHandlers.problem-report-controls.spec.ts | 20 +-- ...colHandlers.problem-report-mapping.spec.ts | 37 ++--- .../ProtocolHandlers.status-responses.spec.ts | 48 +++---- .../ProtocolMessages.constructors.spec.ts | 18 +-- .../PublishTemplateHandler.success.spec.ts | 8 +- .../src/tests/StartHandler.success.spec.ts | 10 +- .../Status.ui-payload.integration.spec.ts | 8 +- .../src/tests/TemplateRefs.valid.spec.ts | 2 +- .../tests/TemplateSchema.additional.spec.ts | 8 +- .../src/tests/TemplateSchema.branches.spec.ts | 12 +- .../src/tests/TemplateSchema.invalid.spec.ts | 8 +- ...lateValidation.additional-coverage.spec.ts | 10 +- .../tests/TemplateValidation.invalid.spec.ts | 14 +- .../workflow/src/tests/WorkflowApi.spec.ts | 27 ++-- ...tanceRepository.queries.additional.spec.ts | 36 ++++- .../tests/WorkflowInstanceRepository.spec.ts | 18 ++- ...flowModule.inbound-events.extended.spec.ts | 11 +- .../WorkflowModule.inbound-events.spec.ts | 41 +++--- .../tests/WorkflowModule.integration.spec.ts | 19 +-- .../WorkflowService.complete-send.spec.ts | 57 ++++---- ...wService.concurrency-and-conflicts.spec.ts | 28 ++-- .../tests/WorkflowService.edge-cases.spec.ts | 131 +++++++++++------- .../tests/WorkflowService.lifecycle.spec.ts | 85 +++++++++--- .../WorkflowService.publish-and-start.spec.ts | 45 +++--- .../WorkflowService.status-options.spec.ts | 39 ++++-- ...orkflowService.template-validation.spec.ts | 50 +++---- .../tests/WorkflowTemplateRepository.spec.ts | 20 ++- 35 files changed, 637 insertions(+), 422 deletions(-) diff --git a/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts index f1c3920a8b..3953cf68ba 100644 --- a/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts +++ b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts @@ -1,9 +1,16 @@ +import type { AgentContext } from '@credo-ts/core' import { IssueCredentialV2Action, PresentProofV2Action } from '..' +import type { ActionCtx } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' -const makeAgentContext = (mocks: any) => ({ +const makeAgentContext = (mocks: { + credentials?: { offerCredential: jest.Mock; findOfferMessage: jest.Mock } + proofs?: { requestProof: jest.Mock; findRequestMessage: jest.Mock } + connections?: { getById: jest.Mock } +}) => ({ dependencyManager: { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('CredentialsApi')) return mocks.credentials if (name.includes('ProofsApi')) return mocks.proofs if (name.includes('ConnectionService')) return mocks.connections @@ -12,7 +19,7 @@ const makeAgentContext = (mocks: any) => ({ }, }) -const baseInstance = { +const baseInstance: WorkflowInstanceData = { instance_id: 'i1', template_id: 't1', template_version: '1.0.0', @@ -29,7 +36,7 @@ const baseInstance = { describe('Action handlers message id retrieval', () => { test('IssueCredentialV2Action uses findOfferMessage id and falls back', async () => { const action = new IssueCredentialV2Action() - const template: any = { + const template = { template_id: 't1', version: '1.0.0', title: 'T', @@ -43,8 +50,8 @@ describe('Action handlers message id retrieval', () => { }, }, }, - } - const actionDef: any = { + } as unknown as WorkflowTemplate + const actionDef = { key: 'offer', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.test', @@ -55,13 +62,13 @@ describe('Action handlers message id retrieval', () => { findOfferMessage: jest.fn(async (_id: string) => ({ message: { id: 'msg-1' } })), } const connections = { getById: jest.fn(async () => ({ theirDid: 'did:example:holder' })) } - const ctx1 = { - agentContext: makeAgentContext({ credentials: credsMock1, connections }), + const ctx1: ActionCtx = { + agentContext: makeAgentContext({ credentials: credsMock1, connections }) as unknown as AgentContext, template, instance: baseInstance, action: actionDef, } - const res1 = await action.execute(ctx1 as any) + const res1 = await action.execute(ctx1) expect(res1.messageId).toBe('msg-1') expect(res1.artifacts?.issueRecordId).toBe('rec-1') // Fallback: findOfferMessage throws → use record id @@ -71,19 +78,19 @@ describe('Action handlers message id retrieval', () => { throw new Error('not found') }), } - const ctx2 = { - agentContext: makeAgentContext({ credentials: credsMock2, connections }), + const ctx2: ActionCtx = { + agentContext: makeAgentContext({ credentials: credsMock2, connections }) as unknown as AgentContext, template, instance: baseInstance, action: actionDef, } - const res2 = await action.execute(ctx2 as any) + const res2 = await action.execute(ctx2) expect(res2.messageId).toBe('rec-2') }) test('PresentProofV2Action uses findRequestMessage id and falls back', async () => { const action = new PresentProofV2Action() - const template: any = { + const template = { template_id: 't1', version: '1.0.0', title: 'T', @@ -98,8 +105,8 @@ describe('Action handlers message id retrieval', () => { }, }, }, - } - const actionDef: any = { + } as unknown as WorkflowTemplate + const actionDef = { key: 'request', typeURI: 'https://didcomm.org/present-proof/2.0/request-presentation', profile_ref: 'pp.test', @@ -109,13 +116,13 @@ describe('Action handlers message id retrieval', () => { findRequestMessage: jest.fn(async (_id: string) => ({ message: { id: 'pmsg-1' } })), } const connections = { getById: jest.fn(async () => ({ theirDid: 'did:example:holder' })) } - const ctx1 = { - agentContext: makeAgentContext({ proofs: proofsMock1, connections }), + const ctx1: ActionCtx = { + agentContext: makeAgentContext({ proofs: proofsMock1, connections }) as unknown as AgentContext, template, instance: baseInstance, action: actionDef, } - const res1 = await action.execute(ctx1 as any) + const res1 = await action.execute(ctx1) expect(res1.messageId).toBe('pmsg-1') expect(res1.artifacts?.proofRecordId).toBe('prec-1') const proofsMock2 = { @@ -124,13 +131,13 @@ describe('Action handlers message id retrieval', () => { throw new Error('not found') }), } - const ctx2 = { - agentContext: makeAgentContext({ proofs: proofsMock2, connections }), + const ctx2: ActionCtx = { + agentContext: makeAgentContext({ proofs: proofsMock2, connections }) as unknown as AgentContext, template, instance: baseInstance, action: actionDef, } - const res2 = await action.execute(ctx2 as any) + const res2 = await action.execute(ctx2) expect(res2.messageId).toBe('prec-2') }) }) diff --git a/packages/workflow/src/tests/Engine.utilities.spec.ts b/packages/workflow/src/tests/Engine.utilities.spec.ts index 5f266c8e12..fb5409bf09 100644 --- a/packages/workflow/src/tests/Engine.utilities.spec.ts +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -1,40 +1,42 @@ import { AttributePlanner, GuardEvaluator } from '..' +import type { AttributeSpec, WorkflowInstanceData } from '..' +import type { GuardEnv } from '../engine/GuardEvaluator' describe('Engine helpers', () => { test('AttributePlanner materialize: context/static/compute', () => { - const plan: any = { + const plan: Record = { a: { source: 'context', path: 'user.name', required: true }, b: { source: 'static', value: 42 }, c: { source: 'compute', expr: "join('', ['hi-','there'])" }, } - const instance: any = { context: { user: { name: 'Alice' } } } + const instance = { context: { user: { name: 'Alice' } } } as unknown as WorkflowInstanceData const out = AttributePlanner.materialize(plan, instance) expect(out).toEqual({ a: 'Alice', b: 42, c: 'hi-there' }) }) test('AttributePlanner required throws missing_attributes', () => { - const plan: any = { req: { source: 'context', path: 'x', required: true } } - const instance: any = { context: {} } + const plan: Record = { req: { source: 'context', path: 'x', required: true } } + const instance = { context: {} } as unknown as WorkflowInstanceData expect(() => AttributePlanner.materialize(plan, instance)).toThrow() }) test('GuardEvaluator evalGuard with JMESPath truthy/falsey', () => { - const env = { context: { a: 1, b: 0 }, participants: {}, artifacts: {} } - expect(GuardEvaluator.evalGuard('context.a', env as any)).toBe(true) - expect(GuardEvaluator.evalGuard('context.b', env as any)).toBe(false) + const env: GuardEnv = { context: { a: 1, b: 0 }, participants: {}, artifacts: {} } + expect(GuardEvaluator.evalGuard('context.a', env)).toBe(true) + expect(GuardEvaluator.evalGuard('context.b', env)).toBe(false) }) test('GuardEvaluator evalValue returns selected JSON piece', () => { - const env = { context: { a: { x: 'ok' } }, participants: {}, artifacts: {} } - expect(GuardEvaluator.evalValue('context.a.x', env as any)).toBe('ok') + const env: GuardEnv = { context: { a: { x: 'ok' } }, participants: {}, artifacts: {} } + expect(GuardEvaluator.evalValue('context.a.x', env)).toBe('ok') }) test('AttributePlanner compute error returns undefined unless required', () => { - const badPlan: any = { + const badPlan: Record = { x: { source: 'compute', expr: 'invalid++expr' }, y: { source: 'compute', expr: 'invalid++expr', required: true }, } - const inst: any = { context: {} } + const inst = { context: {} } as unknown as WorkflowInstanceData // x will be omitted silently expect(() => AttributePlanner.materialize({ x: badPlan.x }, inst)).not.toThrow() // y required → throws diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts index b653c6ffee..d9de401629 100644 --- a/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts +++ b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts @@ -1,43 +1,53 @@ +import type { AgentContext } from '@credo-ts/core' import { IssueCredentialV2Action } from '..' +import type { ActionCtx, ActionDef, WorkflowInstanceData, WorkflowTemplate } from '..' describe('IssueCredentialV2Action', () => { test('missing profile and connection errors, and wraps thrown errors', async () => { const action = new IssueCredentialV2Action() - const template: any = { template_id: 't', version: '1', title: 'T', catalog: { credential_profiles: {} } } - const baseCtx: any = { - agentContext: { dependencyManager: { resolve: () => ({}) } }, + const template = { + template_id: 't', + version: '1', + title: 'T', + catalog: { credential_profiles: {} }, + } as unknown as WorkflowTemplate + const baseCtx = { + agentContext: { dependencyManager: { resolve: () => ({}) } } as unknown as AgentContext, template, - instance: { connection_id: 'c1', participants: {} }, + instance: { connection_id: 'c1', participants: {} } as unknown as WorkflowInstanceData, } // missing profile await expect( - action.execute({ ...baseCtx, action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' } } as any) + action.execute({ + ...(baseCtx as unknown as ActionCtx), + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' } as ActionDef, + }) ).rejects.toHaveProperty('code', 'action_error') // missing connection id await expect( action.execute({ - ...baseCtx, - instance: { participants: {} }, - action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' }, - } as any) + ...(baseCtx as unknown as ActionCtx), + instance: { participants: {} } as unknown as WorkflowInstanceData, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.missing' } as ActionDef, + }) ).rejects.toHaveProperty('code', 'action_error') // wrap thrown error from creds api - const tpl2: any = { + const tpl2 = { template_id: 't', version: '1', title: 'T', catalog: { credential_profiles: { test: { cred_def_id: 'C', attribute_plan: {}, to_ref: 'holder' } } }, - } + } as unknown as WorkflowTemplate const credsApi = { offerCredential: jest.fn(async () => { throw new Error('boom') }), } - const ctx2: any = { - agentContext: { dependencyManager: { resolve: () => credsApi } }, + const ctx2: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => credsApi } } as unknown as AgentContext, template: tpl2, - instance: { connection_id: 'c1', participants: {} }, - action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.test' }, + instance: { connection_id: 'c1', participants: {} } as unknown as WorkflowInstanceData, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'cp.test' } as ActionDef, } await expect(action.execute(ctx2)).rejects.toHaveProperty('code', 'action_error') }) diff --git a/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts index 36673bc6ac..59206931a9 100644 --- a/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts +++ b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts @@ -1,16 +1,23 @@ import 'reflect-metadata' -import { IssueCredentialV2Action } from '..' +import { IssueCredentialV2Action, WorkflowTemplate } from '..' describe('to_ref recipient DID enforcement', () => { test('forbidden when theirDid mismatches participants[to_ref].did', async () => { const action = new IssueCredentialV2Action() - const template: any = { + const template: WorkflowTemplate = { template_id: 't', version: '1.0.0', title: 'T', catalog: { credential_profiles: { test: { cred_def_id: 'cd', attribute_plan: {}, to_ref: 'holder' } } }, + instance_policy: { + mode: 'singleton_per_connection', + multiplicity_key: undefined, + }, + states: [], + transitions: [], + actions: [], } - const instance: any = { + const instance = { connection_id: 'c1', participants: { holder: { did: 'did:example:holder' } }, context: {}, @@ -18,16 +25,16 @@ describe('to_ref recipient DID enforcement', () => { artifacts: {}, history: [], } - const actionDef: any = { + const actionDef: import('..').ActionDef = { key: 'k', typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', profile_ref: 'cp.test', } - const ctx: any = { + const ctx = { agentContext: { dependencyManager: { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('ConnectionService')) return { getById: async () => ({ theirDid: 'did:example:someone-else' }) } if (name.includes('CredentialsApi')) @@ -43,7 +50,7 @@ describe('to_ref recipient DID enforcement', () => { template, instance, action: actionDef, - } + } as unknown as import('..').ActionCtx await expect(action.execute(ctx)).rejects.toHaveProperty('code', 'forbidden') }) }) diff --git a/packages/workflow/src/tests/LocalStateSetAction.spec.ts b/packages/workflow/src/tests/LocalStateSetAction.spec.ts index acea29fc63..099639c508 100644 --- a/packages/workflow/src/tests/LocalStateSetAction.spec.ts +++ b/packages/workflow/src/tests/LocalStateSetAction.spec.ts @@ -1,11 +1,16 @@ +import type { AgentContext } from '@credo-ts/core' import { LocalStateSetAction } from '..' +import type { ActionCtx } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' describe('LocalStateSetAction', () => { test('merges static object', async () => { const act = new LocalStateSetAction() - const ctx: any = { + const ctx: ActionCtx = { + agentContext: {} as unknown as AgentContext, + template: {} as unknown as WorkflowTemplate, action: { key: 'k', typeURI: act.typeUri, staticInput: { merge: { a: { b: 2 } } } }, - instance: { context: { a: { c: 3 } } }, + instance: { context: { a: { c: 3 } } } as unknown as WorkflowInstanceData, } const res = await act.execute(ctx) expect(res.contextMerge).toEqual({ a: { b: 2, c: 3 } }) @@ -14,15 +19,22 @@ describe('LocalStateSetAction', () => { test('returns {} when merge is unresolved string or non-object', async () => { const act = new LocalStateSetAction() // unresolved string path → {} - const ctx1: any = { - action: { staticInput: { merge: '{{ input.form.x }}' }, typeURI: act.typeUri }, - instance: { context: {} }, + const ctx1: ActionCtx = { + agentContext: {} as unknown as AgentContext, + template: {} as unknown as WorkflowTemplate, + action: { staticInput: { merge: '{{ input.form.x }}' }, typeURI: act.typeUri, key: 'k' }, + instance: { context: {} } as unknown as WorkflowInstanceData, input: { form: { x: 1 } }, } const res1 = await act.execute(ctx1) expect(res1).toEqual({}) // merge provided but not object → {} - const ctx2: any = { action: { staticInput: { merge: 42 }, typeURI: act.typeUri }, instance: { context: {} } } + const ctx2: ActionCtx = { + agentContext: {} as unknown as AgentContext, + template: {} as unknown as WorkflowTemplate, + action: { staticInput: { merge: 42 }, typeURI: act.typeUri, key: 'k' }, + instance: { context: {} } as unknown as WorkflowInstanceData, + } const res2 = await act.execute(ctx2) expect(res2).toEqual({}) }) diff --git a/packages/workflow/src/tests/PresentProofV2Action.spec.ts b/packages/workflow/src/tests/PresentProofV2Action.spec.ts index 349e22e106..5fa6857423 100644 --- a/packages/workflow/src/tests/PresentProofV2Action.spec.ts +++ b/packages/workflow/src/tests/PresentProofV2Action.spec.ts @@ -1,9 +1,11 @@ +import type { AgentContext } from '@credo-ts/core' import { PresentProofV2Action } from '..' +import type { ActionCtx, ActionDef, WorkflowInstanceData, WorkflowTemplate } from '..' describe('PresentProofV2Action', () => { test('builds predicates and wraps errors', async () => { const action = new PresentProofV2Action() - const template: any = { + const template = { template_id: 't', version: '1', title: 'T', @@ -17,16 +19,16 @@ describe('PresentProofV2Action', () => { }, }, }, - } + } as unknown as WorkflowTemplate const okApi = { requestProof: jest.fn(async () => ({ id: 'r1' })), findRequestMessage: jest.fn(async () => ({ message: { id: 'm1' } })), } - const ctxOk: any = { - agentContext: { dependencyManager: { resolve: () => okApi } }, + const ctxOk: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => okApi } } as unknown as AgentContext, template, - instance: { connection_id: 'c1', participants: {} }, - action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + instance: { connection_id: 'c1', participants: {} } as unknown as WorkflowInstanceData, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' } as ActionDef, } const res = await action.execute(ctxOk) expect(res.artifacts?.proofRecordId).toBe('r1') @@ -36,34 +38,34 @@ describe('PresentProofV2Action', () => { throw new Error('nope') }), } - const ctxBad: any = { - agentContext: { dependencyManager: { resolve: () => badApi } }, + const ctxBad: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => badApi } } as unknown as AgentContext, template, - instance: { connection_id: 'c1', participants: {} }, - action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + instance: { connection_id: 'c1', participants: {} } as unknown as WorkflowInstanceData, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' } as ActionDef, } await expect(action.execute(ctxBad)).rejects.toHaveProperty('code', 'action_error') }) test('without restriction (no schema_id/cred_def_id)', async () => { const action = new PresentProofV2Action() - const template: any = { + const template = { template_id: 't', version: '1', title: 'T', catalog: { proof_profiles: { test: { requested_attributes: ['name'], requested_predicates: [], to_ref: 'holder' } }, }, - } + } as unknown as WorkflowTemplate const proofsApi = { requestProof: jest.fn(async () => ({ id: 'r2' })), findRequestMessage: jest.fn(async () => ({ message: { id: 'm2' } })), } - const ctx: any = { - agentContext: { dependencyManager: { resolve: () => proofsApi } }, + const ctx: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => proofsApi } } as unknown as AgentContext, template, - instance: { connection_id: 'c1', participants: {} }, - action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' }, + instance: { connection_id: 'c1', participants: {} } as unknown as WorkflowInstanceData, + action: { key: 'a', typeURI: action.typeUri, profile_ref: 'pp.test' } as ActionDef, } const res = await action.execute(ctx) expect(res.artifacts?.proofRecordId).toBe('r2') diff --git a/packages/workflow/src/tests/ProblemReportHandler.spec.ts b/packages/workflow/src/tests/ProblemReportHandler.spec.ts index dc9f298dcd..3839c49d63 100644 --- a/packages/workflow/src/tests/ProblemReportHandler.spec.ts +++ b/packages/workflow/src/tests/ProblemReportHandler.spec.ts @@ -5,9 +5,9 @@ describe('ProblemReportHandler', () => { const handler = new ProblemReportHandler() const msg = new ProblemReportMessage({ body: { code: 'invalid_event', comment: 'no local instance' }, thid: 't1' }) const res = await handler.handle({ - agentContext: { dependencyManager: { resolve: (_: any) => ({ logger: { warn() {} } }) } }, + agentContext: { dependencyManager: { resolve: (_: unknown) => ({ logger: { warn() {} } }) } }, message: msg, - } as any) + } as never) expect(res).toBeUndefined() }) }) diff --git a/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts index e06e609567..5585b19c13 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts @@ -11,15 +11,14 @@ import { const makeAgentContext = () => ({ dependencyManager: { - resolve: (ctor: any) => { + resolve: (ctor: unknown) => { if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) - if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } - return {} + return { logger: { info() {}, warn() {}, debug() {} } } }, }, }) -const inbound = (message: any) => ({ agentContext: makeAgentContext(), connection: { id: 'c1' }, message }) as any +const inbound = (message: unknown) => ({ agentContext: makeAgentContext(), connection: { id: 'c1' }, message }) describe('Handlers extra cases', () => { test('PublishTemplateHandler sends problem-report on error', async () => { @@ -28,10 +27,11 @@ describe('Handlers extra cases', () => { throw Object.assign(new Error('bad'), { code: 'invalid_template' }) }, } - const handler = new PublishTemplateHandler(svc as any) - const msg: any = { type: 'https://didcomm.org/workflow/1.0/publish-template', body: { template: {} } } - const ctx = await handler.handle(inbound(msg)) - expect((ctx as any)?.message?.body?.code).toBe('invalid_template') + const handler = new PublishTemplateHandler(svc as unknown as import('..').WorkflowService) + const msg = { type: 'https://didcomm.org/workflow/1.0/publish-template', body: { template: {} } } + const ctx = await handler.handle(inbound(msg) as never) + const code = (ctx as unknown as { message?: { body?: { code?: string } } })?.message?.body?.code + expect(code).toBe('invalid_template') }) test('CompleteHandler ignores invalid_event (no local instance)', async () => { @@ -41,9 +41,9 @@ describe('Handlers extra cases', () => { }, status: async () => ({}), } - const handler = new CompleteHandler(svc as any) - const msg: any = { body: { instance_id: 'i1' }, threadId: 'i1' } - const res = await handler.handle(inbound(msg)) + const handler = new CompleteHandler(svc as unknown as import('..').WorkflowService) + const msg = { body: { instance_id: 'i1' }, threadId: 'i1' } + const res = await handler.handle(inbound(msg) as never) expect(res).toBeUndefined() }) @@ -60,17 +60,20 @@ describe('Handlers extra cases', () => { }, status: async () => ({}), } - const pause = new PauseHandler(svc as any) - const resume = new ResumeHandler(svc as any) - const cancel = new CancelHandler(svc as any) - expect(await pause.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() - expect(await resume.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() - expect(await cancel.handle(inbound({ body: { instance_id: 'i1' } }))).toBeUndefined() + const pause = new PauseHandler(svc as unknown as import('..').WorkflowService) + const resume = new ResumeHandler(svc as unknown as import('..').WorkflowService) + const cancel = new CancelHandler(svc as unknown as import('..').WorkflowService) + expect(await pause.handle(inbound({ body: { instance_id: 'i1' } }) as never)).toBeUndefined() + expect(await resume.handle(inbound({ body: { instance_id: 'i1' } }) as never)).toBeUndefined() + expect(await cancel.handle(inbound({ body: { instance_id: 'i1' } }) as never)).toBeUndefined() }) test('StatusHandler forwards include flags', async () => { const svc = { - status: async (_ctx: any, opts: any) => ({ + status: async ( + _ctx: unknown, + opts: { instance_id: string; include_actions?: boolean; include_ui?: boolean } + ) => ({ instance_id: opts.instance_id, state: 's', allowed_events: [], @@ -79,10 +82,14 @@ describe('Handlers extra cases', () => { ui: [{}], }), } - const handler = new StatusHandler(svc as any) + const handler = new StatusHandler(svc as unknown as import('..').WorkflowService) const message = new StatusRequestMessage({ body: { instance_id: 'i1', include_actions: false, include_ui: true } }) - const ctx = await handler.handle(inbound(message)) - const body = (ctx as any).message.body + const ctx = await handler.handle(inbound(message) as never) + const body = (ctx as unknown as { message: { body: Record } }).message.body as unknown as { + instance_id: string + action_menu: unknown[] + ui?: unknown[] + } expect(body.instance_id).toBe('i1') expect(Array.isArray(body.ui)).toBe(true) expect(body.action_menu).toEqual([]) diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts index f1c9f1b4af..e3528734a2 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts @@ -2,15 +2,14 @@ import { CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowMo const makeCtx = () => ({ dependencyManager: { - resolve: (ctor: any) => { + resolve: (ctor: unknown) => { if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) - if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } - return {} + return { logger: { info() {}, warn() {}, debug() {} } } }, }, }) -const inbound = (message: any) => ({ agentContext: makeCtx(), connection: { id: 'c1' }, message }) as any +const inbound = (message: unknown) => ({ agentContext: makeCtx(), connection: { id: 'c1' }, message }) describe('Handlers problem-report (non-invalid_event)', () => { test('Cancel/Pause/Resume/Complete return problem-report on error code', async () => { @@ -29,13 +28,14 @@ describe('Handlers problem-report (non-invalid_event)', () => { }, status: async () => ({}), } - const cancel = new CancelHandler(svc as any) - const pause = new PauseHandler(svc as any) - const resume = new ResumeHandler(svc as any) - const complete = new CompleteHandler(svc as any) + const cancel = new CancelHandler(svc as unknown as import('..').WorkflowService) + const pause = new PauseHandler(svc as unknown as import('..').WorkflowService) + const resume = new ResumeHandler(svc as unknown as import('..').WorkflowService) + const complete = new CompleteHandler(svc as unknown as import('..').WorkflowService) for (const h of [cancel, pause, resume, complete]) { - const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) - expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/problem-report') + const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' }) as never) + const msgType = (res as unknown as { message?: { type?: string } })?.message?.type + expect(msgType).toBe('https://didcomm.org/workflow/1.0/problem-report') } }) }) diff --git a/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts index a606e555f1..70d22f2802 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts @@ -10,20 +10,18 @@ import { const makeAgentContext = () => ({ dependencyManager: { - resolve: (ctor: any) => { + resolve: (ctor: unknown) => { if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) - if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } - return {} + return { logger: { info() {}, warn() {}, debug() {} } } }, }, }) -const makeInbound = (message: any) => - ({ - agentContext: makeAgentContext(), - connection: { id: 'conn1' }, - message, - }) as any +const makeInbound = (message: unknown) => ({ + agentContext: makeAgentContext(), + connection: { id: 'conn1' }, + message, +}) describe('Handlers problem-report mapping', () => { test('StartHandler sends problem-report on error', async () => { @@ -33,10 +31,11 @@ describe('Handlers problem-report mapping', () => { }, status: async () => ({}), } - const handler = new StartHandler(svc as any) + const handler = new StartHandler(svc as unknown as import('..').WorkflowService) const message = new StartMessage({ body: { template_id: 'x' } }) - const res = await handler.handle(makeInbound(message)) - expect((res as any)?.message?.body?.code).toBe('guard_failed') + const res = await handler.handle(makeInbound(message) as never) + const code = (res as unknown as { message?: { body?: { code?: string } } })?.message?.body?.code + expect(code).toBe('guard_failed') }) test('AdvanceHandler sends problem-report on error', async () => { @@ -46,10 +45,11 @@ describe('Handlers problem-report mapping', () => { }, status: async () => ({}), } - const handler = new AdvanceHandler(svc as any) + const handler = new AdvanceHandler(svc as unknown as import('..').WorkflowService) const message = new AdvanceMessage({ body: { instance_id: 'i1', event: 'e' } }) - const res = await handler.handle(makeInbound(message)) - expect((res as any)?.message?.body?.code).toBe('invalid_event') + const res = await handler.handle(makeInbound(message) as never) + const code2 = (res as unknown as { message?: { body?: { code?: string } } })?.message?.body?.code + expect(code2).toBe('invalid_event') }) test('StatusHandler sends problem-report on error', async () => { @@ -58,9 +58,10 @@ describe('Handlers problem-report mapping', () => { throw Object.assign(new Error('nope'), { code: 'forbidden' }) }, } - const handler = new StatusHandler(svc as any) + const handler = new StatusHandler(svc as unknown as import('..').WorkflowService) const message = new StatusRequestMessage({ body: { instance_id: 'i1' } }) - const res = await handler.handle(makeInbound(message)) - expect((res as any)?.message?.body?.code).toBe('forbidden') + const res = await handler.handle(makeInbound(message) as never) + const code3 = (res as unknown as { message?: { body?: { code?: string } } })?.message?.body?.code + expect(code3).toBe('forbidden') }) }) diff --git a/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts index 16f6cc889e..b44e801931 100644 --- a/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts +++ b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts @@ -1,23 +1,15 @@ -import { - AdvanceHandler, - CancelHandler, - CompleteHandler, - PauseHandler, - ResumeHandler, - WorkflowModuleConfig, -} from '..' +import { AdvanceHandler, CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '..' const ctx = () => ({ dependencyManager: { - resolve: (ctor: any) => { + resolve: (ctor: unknown) => { if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) - if (ctor?.name?.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } - return {} + return { logger: { info() {}, warn() {}, debug() {} } } }, }, }) -const inbound = (message: any) => ({ agentContext: ctx(), connection: { id: 'c1' }, message }) as any +const inbound = (message: unknown) => ({ agentContext: ctx(), connection: { id: 'c1' }, message }) describe('Handlers success responses', () => { test('Pause/Resume/Cancel → StatusMessage response', async () => { @@ -28,14 +20,16 @@ describe('Handlers success responses', () => { cancel: jest.fn(async () => ({})), status: jest.fn(async () => status), } - const pause = new PauseHandler(svc as any) - const resume = new ResumeHandler(svc as any) - const cancel = new CancelHandler(svc as any) - const resP = await pause.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) - const resR = await resume.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) - const resC = await cancel.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) + const pause = new PauseHandler(svc as unknown as import('..').WorkflowService) + const resume = new ResumeHandler(svc as unknown as import('..').WorkflowService) + const cancel = new CancelHandler(svc as unknown as import('..').WorkflowService) + const resP = await pause.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' }) as never) + const resR = await resume.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' }) as never) + const resC = await cancel.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' }) as never) for (const res of [resP, resR, resC]) { - expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + expect((res as unknown as { message?: { type?: string } })?.message?.type).toBe( + 'https://didcomm.org/workflow/1.0/status' + ) } }) @@ -50,9 +44,11 @@ describe('Handlers success responses', () => { artifacts: {}, })), } - const h = new CompleteHandler(svc as any) - const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' })) - expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + const h = new CompleteHandler(svc as unknown as import('..').WorkflowService) + const res = await h.handle(inbound({ body: { instance_id: 'i1' }, threadId: 'i1' }) as never) + expect((res as unknown as { message?: { type?: string } })?.message?.type).toBe( + 'https://didcomm.org/workflow/1.0/status' + ) }) test('AdvanceHandler success with mismatched thid vs instance_id logs warn path', async () => { @@ -66,8 +62,10 @@ describe('Handlers success responses', () => { artifacts: {}, })), } - const h = new AdvanceHandler(svc as any) - const res = await h.handle(inbound({ body: { instance_id: 'i1', event: 'go' }, threadId: 'th-other' })) - expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + const h = new AdvanceHandler(svc as unknown as import('..').WorkflowService) + const res = await h.handle(inbound({ body: { instance_id: 'i1', event: 'go' }, threadId: 'th-other' }) as never) + expect((res as unknown as { message?: { type?: string } })?.message?.type).toBe( + 'https://didcomm.org/workflow/1.0/status' + ) }) }) diff --git a/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts index e36eb43537..0a89825ce4 100644 --- a/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts +++ b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts @@ -1,11 +1,5 @@ -import { - CancelMessage, - CompleteMessage, - PauseMessage, - PublishTemplateMessage, - ResumeMessage, - StatusMessage, -} from '..' +import { CancelMessage, CompleteMessage, PauseMessage, PublishTemplateMessage, ResumeMessage, StatusMessage } from '..' +import type { WorkflowTemplate } from '..' describe('Protocol messages constructors', () => { test('Pause/Resume/Cancel/Complete set type, body and thread', () => { @@ -54,18 +48,18 @@ describe('Protocol messages constructors', () => { }) test('PublishTemplateMessage sets type and embeds template', () => { - const tpl: any = { + const tpl: WorkflowTemplate = { template_id: 't', version: '1.0.0', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, - states: [{ name: 's', type: 'start' }], + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], transitions: [], catalog: {}, actions: [], } const m = new PublishTemplateMessage({ body: { template: tpl, mode: 'upsert' } }) expect(m.type).toBe('https://didcomm.org/workflow/1.0/publish-template') - expect((m.body as any).template.template_id).toBe('t') + expect(m.body.template.template_id).toBe('t') }) }) diff --git a/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts index 14996c7185..709b15113b 100644 --- a/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts +++ b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts @@ -3,8 +3,8 @@ import { PublishTemplateHandler } from '..' describe('PublishTemplateHandler success', () => { test('returns undefined on success (no outbound message)', async () => { const svc = { publishTemplate: jest.fn(async () => {}) } - const handler = new PublishTemplateHandler(svc as any) - const msg: any = { + const handler = new PublishTemplateHandler(svc as unknown as import('..').WorkflowService) + const msg = { body: { template: { template_id: 't', @@ -20,10 +20,10 @@ describe('PublishTemplateHandler success', () => { } const res = await handler.handle({ agentContext: { - dependencyManager: { resolve: (_ctor: any) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + dependencyManager: { resolve: (_ctor: unknown) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, }, message: msg, - } as any) + } as never) expect(res).toBeUndefined() }) }) diff --git a/packages/workflow/src/tests/StartHandler.success.spec.ts b/packages/workflow/src/tests/StartHandler.success.spec.ts index 7e51f9a06f..f6753f35a0 100644 --- a/packages/workflow/src/tests/StartHandler.success.spec.ts +++ b/packages/workflow/src/tests/StartHandler.success.spec.ts @@ -13,14 +13,16 @@ describe('StartHandler success', () => { artifacts: {}, })), } - const handler = new StartHandler(svc as any) + const handler = new StartHandler(svc as unknown as import('..').WorkflowService) const res = await handler.handle({ agentContext: { - dependencyManager: { resolve: (_ctor: any) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + dependencyManager: { resolve: (_ctor: unknown) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, }, connection: { id: 'c1' }, message: { body: { template_id: 't' }, id: 'id1' }, - } as any) - expect((res as any)?.message?.type).toBe('https://didcomm.org/workflow/1.0/status') + } as never) + expect((res as unknown as { message?: { type?: string } })?.message?.type).toBe( + 'https://didcomm.org/workflow/1.0/status' + ) }) }) diff --git a/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts index dc798d44af..4258d2edea 100644 --- a/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts +++ b/packages/workflow/src/tests/Status.ui-payload.integration.spec.ts @@ -1,5 +1,5 @@ import { AskarModule } from '@credo-ts/askar' -import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { Agent, AgentConfig, ConsoleLogger, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { askar } from '@openwallet-foundation/askar-nodejs' import { WorkflowModule } from '..' @@ -10,7 +10,7 @@ const makeAgent = async () => { label: 'wf-status-ui-test', logger: new ConsoleLogger(LogLevel.off), walletConfig: { id: 'wf-status-ui-test', key: 'wf-status-ui-test' }, - }, + } as unknown as AgentConfig, dependencies: agentDependencies, modules: { askar: new AskarModule({ @@ -41,7 +41,7 @@ describe('Status UI payload', () => { { type: 'button', label: 'Go', event: 'go' }, { type: 'submit-button', label: 'Submit', event: 'submit' }, ] - const tpl = { + const tpl: import('..').WorkflowTemplate = { template_id: 'ui-tpl', version: '1.0.0', title: 'UI Demo', @@ -56,7 +56,7 @@ describe('Status UI payload', () => { actions: [], display_hints: { states: { menu: ui } }, } - await agent.modules.workflow.publishTemplate(tpl as any) + await agent.modules.workflow.publishTemplate(tpl) const inst = await agent.modules.workflow.start({ template_id: 'ui-tpl' }) const s1 = await agent.modules.workflow.status({ instance_id: inst.instanceId, include_ui: true }) diff --git a/packages/workflow/src/tests/TemplateRefs.valid.spec.ts b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts index b5f50ff073..e261c46137 100644 --- a/packages/workflow/src/tests/TemplateRefs.valid.spec.ts +++ b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts @@ -2,7 +2,7 @@ import { validateTemplateRefs } from '..' describe('validateTemplateRefs positive (cp.* and pp.*)', () => { test('cp.* and pp.* profile_ref resolve to catalog entries', () => { - const tpl: any = { + const tpl: import('..').WorkflowTemplate = { template_id: 't', version: '1', title: 'T', diff --git a/packages/workflow/src/tests/TemplateSchema.additional.spec.ts b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts index 0c8d4c8880..93308c50c8 100644 --- a/packages/workflow/src/tests/TemplateSchema.additional.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts @@ -2,7 +2,7 @@ import { validateTemplateJson } from '..' describe('Schemas extra invalid cases', () => { test('invalid instance_policy (missing mode)', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', @@ -16,7 +16,7 @@ describe('Schemas extra invalid cases', () => { }) test('transitions invalid item shape', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', @@ -30,7 +30,7 @@ describe('Schemas extra invalid cases', () => { }) test('actions invalid profile_ref pattern rejected by schema', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', @@ -44,7 +44,7 @@ describe('Schemas extra invalid cases', () => { }) test('sections invalid item (missing name)', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', diff --git a/packages/workflow/src/tests/TemplateSchema.branches.spec.ts b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts index 313b47c2c0..44a72a7ccc 100644 --- a/packages/workflow/src/tests/TemplateSchema.branches.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts @@ -13,22 +13,22 @@ const base = { describe('schemas.ts JSON-schema branches', () => { test('root additionalProperties=false', () => { - const bad: any = { ...base, foo: 'bar' } + const bad = { ...base, foo: 'bar' } expect(() => validateTemplateJson(bad)).toThrow() }) test('instance_policy additionalProperties=false', () => { - const bad: any = { ...base, instance_policy: { mode: 'multi_per_connection', extra: 1 } } + const bad = { ...base, instance_policy: { mode: 'multi_per_connection', extra: 1 } } expect(() => validateTemplateJson(bad)).toThrow() }) test('transitions item additionalProperties=false', () => { - const bad: any = { ...base, transitions: [{ from: 'a', to: 'a', on: 'x', extra: true }] } + const bad = { ...base, transitions: [{ from: 'a', to: 'a', on: 'x', extra: true }] } expect(() => validateTemplateJson(bad)).toThrow() }) test('credential_profiles entry additionalProperties=false', () => { - const bad: any = { + const bad = { ...base, catalog: { credential_profiles: { x: { cred_def_id: 'C', attribute_plan: {}, to_ref: 'holder', extra: 'no' } } }, } @@ -36,7 +36,7 @@ describe('schemas.ts JSON-schema branches', () => { }) test('proof_profiles entry additionalProperties=false', () => { - const bad: any = { + const bad = { ...base, catalog: { proof_profiles: { x: { to_ref: 'holder', extra: 'no' } } }, } @@ -44,7 +44,7 @@ describe('schemas.ts JSON-schema branches', () => { }) test('requested_predicates item additionalProperties=false', () => { - const bad: any = { + const bad = { ...base, catalog: { proof_profiles: { diff --git a/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts index f883d2e853..6c8b1d1ea3 100644 --- a/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts +++ b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts @@ -2,7 +2,7 @@ import { validateTemplateJson } from '..' describe('schemas.ts more invalids', () => { test('transitions missing from', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', @@ -16,7 +16,7 @@ describe('schemas.ts more invalids', () => { }) test('transitions missing to', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', @@ -30,7 +30,7 @@ describe('schemas.ts more invalids', () => { }) test('states item missing name', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', @@ -44,7 +44,7 @@ describe('schemas.ts more invalids', () => { }) test('proof requested_predicates item missing p_value', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', diff --git a/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts index 4e8c835382..fb97b67239 100644 --- a/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts +++ b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts @@ -2,7 +2,7 @@ import { validateTemplateJson, validateTemplateRefs } from '..' describe('schemas.ts additional coverage', () => { test('transitions missing on', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', @@ -16,7 +16,7 @@ describe('schemas.ts additional coverage', () => { }) test('state missing type', () => { - const bad: any = { + const bad: unknown = { template_id: 't', version: '1', title: 'T', @@ -30,7 +30,7 @@ describe('schemas.ts additional coverage', () => { }) test('attribute_plan invalid variants', () => { - const base: any = { + const base: Record = { template_id: 't', version: '1', title: 'T', @@ -71,7 +71,7 @@ describe('schemas.ts additional coverage', () => { }) test('validateTemplateRefs transition.from unknown', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', @@ -81,6 +81,6 @@ describe('schemas.ts additional coverage', () => { catalog: {}, actions: [], } - expect(() => validateTemplateRefs(bad)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) }) diff --git a/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts index 25c65b4830..774b8c5092 100644 --- a/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts +++ b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts @@ -15,18 +15,18 @@ const baseTpl = { describe('Template invalid cases (schema/refs)', () => { test('missing start state (passes JSON schema, fails refs)', () => { const bad = { ...baseTpl, states: [{ name: 'x', type: 'normal' }] } - expect(() => validateTemplateJson(bad as any)).not.toThrow() - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateJson(bad)).not.toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) test('unknown state.section', () => { const bad = { ...baseTpl, states: [{ name: 's', type: 'start', section: 'Unknown' }] } - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) test('transition.action unknown', () => { const bad = { ...baseTpl, transitions: [{ from: 's', to: 's', on: 'go', action: 'missing' }] } - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) test('transition.to unknown', () => { @@ -35,16 +35,16 @@ describe('Template invalid cases (schema/refs)', () => { states: [{ name: 's', type: 'start', section: 'Main' }], transitions: [{ from: 's', to: 'x', on: 'go' }], } - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) test('profile_ref missing in catalog (cp.)', () => { const bad = { ...baseTpl, actions: [{ key: 'a', typeURI: 'x', profile_ref: 'cp.unknown' }] } - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) test('profile_ref missing in catalog (pp.)', () => { const bad = { ...baseTpl, actions: [{ key: 'a', typeURI: 'x', profile_ref: 'pp.unknown' }] } - expect(() => validateTemplateRefs(bad as any)).toThrow() + expect(() => validateTemplateRefs(bad as unknown as import('..').WorkflowTemplate)).toThrow() }) }) diff --git a/packages/workflow/src/tests/WorkflowApi.spec.ts b/packages/workflow/src/tests/WorkflowApi.spec.ts index 0e22354eec..d577988bad 100644 --- a/packages/workflow/src/tests/WorkflowApi.spec.ts +++ b/packages/workflow/src/tests/WorkflowApi.spec.ts @@ -1,31 +1,34 @@ -import { WorkflowApi } from '..' +import { WorkflowApi, WorkflowService } from '..' describe('WorkflowApi pass-through', () => { const make = () => { const service = { - publishTemplate: jest.fn(async (_ctx: any, t: any) => ({ id: 'tpl', template: t })), - start: jest.fn(async (_ctx: any, o: any) => ({ id: o.instance_id || 'id1', instanceId: 'i1' })), - advance: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), - status: jest.fn(async (_ctx: any, o: any) => ({ + publishTemplate: jest.fn(async (_ctx: unknown, t: unknown) => ({ id: 'tpl', template: t })), + start: jest.fn(async (_ctx: unknown, o: { instance_id?: string }) => ({ + id: o.instance_id || 'id1', + instanceId: 'i1', + })), + advance: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instanceId: o.instance_id })), + status: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instance_id: o.instance_id, state: 's', allowed_events: [], action_menu: [], artifacts: {}, })), - pause: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), - resume: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), - cancel: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), - complete: jest.fn(async (_ctx: any, o: any) => ({ instanceId: o.instance_id })), + pause: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instanceId: o.instance_id })), + resume: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instanceId: o.instance_id })), + cancel: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instanceId: o.instance_id })), + complete: jest.fn(async (_ctx: unknown, o: { instance_id: string }) => ({ instanceId: o.instance_id })), } - const agentContext: any = {} - const api = new WorkflowApi(service as any, agentContext) + const agentContext = {} as unknown as import('@credo-ts/core').AgentContext + const api = new WorkflowApi(service as unknown as WorkflowService, agentContext) return { api, service } } test('publishes template', async () => { const { api, service } = make() - const tpl: any = { + const tpl: import('..').WorkflowTemplate = { template_id: 't', version: '1.0.0', title: 'T', diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts index 7638fb81c3..b987ff9407 100644 --- a/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts @@ -1,10 +1,22 @@ +import type { AgentContext, EventEmitter, StorageService } from '@credo-ts/core' import { WorkflowInstanceRepository } from '..' +import type { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' describe('WorkflowInstanceRepository filters', () => { test('findByTemplateConnAndMultiplicity passes all filters', async () => { - const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) - const spy = jest.spyOn(repo as any, 'findByQuery').mockResolvedValue([{ id: 'x' }]) - const out = await repo.findByTemplateConnAndMultiplicity({} as any, 'tpl', 'conn', 'K') + const repo = new WorkflowInstanceRepository( + {} as unknown as StorageService, + { on: () => {} } as unknown as EventEmitter + ) + const spy = jest + .spyOn( + repo as unknown as { + findByQuery: (ctx: AgentContext, q: Record) => Promise> + }, + 'findByQuery' + ) + .mockResolvedValue([{ id: 'x' }]) + const out = await repo.findByTemplateConnAndMultiplicity({} as unknown as AgentContext, 'tpl', 'conn', 'K') expect(spy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ templateId: 'tpl', connectionId: 'conn', multiplicityKeyValue: 'K' }) @@ -13,10 +25,20 @@ describe('WorkflowInstanceRepository filters', () => { }) test('getByInstanceId calls findSingleByQuery', async () => { - const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) - const spy = jest.spyOn(repo as any, 'findSingleByQuery').mockResolvedValue({ id: 'y' }) - const rec = await repo.getByInstanceId({} as any, 'inst') + const repo = new WorkflowInstanceRepository( + {} as unknown as StorageService, + { on: () => {} } as unknown as EventEmitter + ) + const spy = jest + .spyOn( + repo as unknown as { + findSingleByQuery: (ctx: AgentContext, q: Record) => Promise<{ id: string }> + }, + 'findSingleByQuery' + ) + .mockResolvedValue({ id: 'y' }) + const rec = await repo.getByInstanceId({} as unknown as AgentContext, 'inst') expect(spy).toHaveBeenCalledWith(expect.anything(), { instanceId: 'inst' }) - expect(rec.id).toBe('y') + expect((rec as unknown as { id: string }).id).toBe('y') }) }) diff --git a/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts index 861dff33a8..48d6193ce7 100644 --- a/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts @@ -1,15 +1,25 @@ +import type { AgentContext, EventEmitter, StorageService } from '@credo-ts/core' import { WorkflowInstanceRepository } from '..' +import type { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' describe('WorkflowInstanceRepository helpers', () => { test('findLatestByConnection returns most recent by updatedAt/createdAt', async () => { - const repo = new WorkflowInstanceRepository({} as any, { on: () => {} } as any) - const list: any[] = [ + const repo = new WorkflowInstanceRepository( + {} as unknown as StorageService, + { on: () => {} } as unknown as EventEmitter + ) + const list: Array<{ id: string; updatedAt?: Date; createdAt?: Date }> = [ { id: 'a', updatedAt: new Date('2020-01-01'), createdAt: new Date('2020-01-01') }, { id: 'b', updatedAt: new Date('2021-01-01'), createdAt: new Date('2020-06-01') }, { id: 'c', createdAt: new Date('2022-01-01') }, ] - jest.spyOn(repo as any, 'findByConnection').mockResolvedValue(list) - const latest = await repo.findLatestByConnection({} as any, 'c1') + jest + .spyOn( + repo as unknown as { findByConnection: (ctx: AgentContext, id: string) => Promise }, + 'findByConnection' + ) + .mockResolvedValue(list) + const latest = await repo.findLatestByConnection({} as unknown as AgentContext, 'c1') expect(latest?.id).toBe('c') }) }) diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts index 9aa65d772f..3d2b48f90d 100644 --- a/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts @@ -4,22 +4,23 @@ import { DidCommProofEventTypes, DidCommProofState, } from '@credo-ts/didcomm' +import { AgentContext } from 'packages/core/src' import { WorkflowModule } from '..' describe('WorkflowModule event mapping (Done branches)', () => { test('maps Done branches for credentials and proofs', async () => { const module = new WorkflowModule({}) - const listeners: Record = {} + const listeners: Record void> = {} const service = { autoAdvanceByConnection: jest.fn(async () => {}) } const dm = { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } if (name.includes('FeatureRegistry')) return { register: () => {} } if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: () => {} } if (name.includes('EventEmitter')) return { - on: (evt: string, cb: Function) => { + on: (evt: string, cb: (...args: unknown[]) => void) => { listeners[evt] = cb }, } @@ -27,7 +28,7 @@ describe('WorkflowModule event mapping (Done branches)', () => { return {} }, } - await module.initialize({ dependencyManager: dm } as any) + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ payload: { credentialExchangeRecord: { connectionId: 'c', state: DidCommCredentialState.Done } }, }) diff --git a/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts index 4c88640f9e..b580374aac 100644 --- a/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts @@ -4,23 +4,25 @@ import { DidCommProofEventTypes, DidCommProofState, } from '@credo-ts/didcomm' +import { AgentContext, Constructor } from 'packages/core/src' import { WorkflowModule } from '..' describe('WorkflowModule event mapping', () => { test('maps credential/proof events to workflow autoAdvance', async () => { const module = new WorkflowModule({}) - const handlers: any[] = [] - const listeners: Record = {} + const handlers: unknown[] = [] + const listeners: Record void> = {} const service = { autoAdvanceByConnection: jest.fn(async () => {}) } const dm = { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } if (name.includes('FeatureRegistry')) return { register: () => {} } - if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: (h: any) => handlers.push(h) } + if (name.includes('MessageHandlerRegistry')) + return { registerMessageHandler: (h: Constructor) => handlers.push(h) } if (name.includes('EventEmitter')) return { - on: (evt: string, cb: Function) => { + on: (evt: string, cb: (...args: unknown[]) => void) => { listeners[evt] = cb }, } @@ -28,7 +30,7 @@ describe('WorkflowModule event mapping', () => { return {} }, } - await module.initialize({ dependencyManager: dm } as any) + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ payload: { credentialExchangeRecord: { connectionId: 'c1', state: DidCommCredentialState.RequestReceived } }, }) @@ -49,18 +51,19 @@ describe('WorkflowModule event mapping', () => { test('ignores events without connectionId', async () => { const module = new WorkflowModule({}) - const handlers: any[] = [] - const listeners: Record = {} + const handlers: unknown[] = [] + const listeners: Record void> = {} const service = { autoAdvanceByConnection: jest.fn(async () => {}) } const dm = { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: Constructor) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } if (name.includes('FeatureRegistry')) return { register: () => {} } - if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: (h: any) => handlers.push(h) } + if (name.includes('MessageHandlerRegistry')) + return { registerMessageHandler: (h: Constructor) => handlers.push(h) } if (name.includes('EventEmitter')) return { - on: (evt: string, cb: Function) => { + on: (evt: string, cb: (...args: unknown[]) => void) => { listeners[evt] = cb }, } @@ -68,7 +71,7 @@ describe('WorkflowModule event mapping', () => { return {} }, } - await module.initialize({ dependencyManager: dm } as any) + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) // Fire events with no connectionId listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ payload: { credentialExchangeRecord: { state: DidCommCredentialState.RequestReceived } }, @@ -81,17 +84,17 @@ describe('WorkflowModule event mapping', () => { test('no-op for unrelated states (neither mapped branch)', async () => { const module = new WorkflowModule({}) - const listeners: Record = {} + const listeners: Record void> = {} const service = { autoAdvanceByConnection: jest.fn(async () => {}) } const dm = { - resolve: (ctor: any) => { - const name = ctor?.name || '' + resolve: (ctor: Constructor) => { + const name = (ctor as { name?: string })?.name || '' if (name.includes('AgentConfig')) return { logger: { info() {}, warn() {}, debug() {} } } if (name.includes('FeatureRegistry')) return { register: () => {} } if (name.includes('MessageHandlerRegistry')) return { registerMessageHandler: () => {} } if (name.includes('EventEmitter')) return { - on: (evt: string, cb: Function) => { + on: (evt: string, cb: (...args: unknown[]) => void) => { listeners[evt] = cb }, } @@ -99,7 +102,7 @@ describe('WorkflowModule event mapping', () => { return {} }, } - await module.initialize({ dependencyManager: dm } as any) + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) listeners[DidCommCredentialEventTypes.DidCommCredentialStateChanged]?.({ payload: { credentialExchangeRecord: { connectionId: 'c', state: 'offer-sent' } }, }) diff --git a/packages/workflow/src/tests/WorkflowModule.integration.spec.ts b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts index 0ddff7f421..2f46c9cd42 100644 --- a/packages/workflow/src/tests/WorkflowModule.integration.spec.ts +++ b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts @@ -1,8 +1,8 @@ import { AskarModule } from '@credo-ts/askar' -import { Agent, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { Agent, AgentConfig, ConsoleLogger, LogLevel } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { askar } from '@openwallet-foundation/askar-nodejs' -import { WorkflowModule } from '..' +import { WorkflowModule, WorkflowTemplate } from '..' const makeAgent = async () => { const agent = new Agent({ @@ -10,7 +10,7 @@ const makeAgent = async () => { label: 'wf-test', logger: new ConsoleLogger(LogLevel.off), walletConfig: { id: 'wf-test', key: 'wf-test' }, - }, + } as unknown as AgentConfig, dependencies: agentDependencies, modules: { askar: new AskarModule({ @@ -41,7 +41,7 @@ describe('Workflow module', () => { catalog: {}, actions: [], } - await agent.modules.workflow.publishTemplate(tpl as any) + await agent.modules.workflow.publishTemplate(tpl as unknown as WorkflowTemplate) await agent.shutdown() }) @@ -57,7 +57,10 @@ describe('Workflow module', () => { catalog: {}, actions: [], } - await expect(agent.modules.workflow.publishTemplate(bad as any)).rejects.toHaveProperty('code', 'invalid_template') + await expect(agent.modules.workflow.publishTemplate(bad as unknown as WorkflowTemplate)).rejects.toHaveProperty( + 'code', + 'invalid_template' + ) await agent.shutdown() }) @@ -73,7 +76,7 @@ describe('Workflow module', () => { catalog: {}, actions: [], } - await agent.modules.workflow.publishTemplate(tpl as any) + await agent.modules.workflow.publishTemplate(tpl as unknown as WorkflowTemplate) const a = await agent.modules.workflow.start({ template_id: 'single', connection_id: 'conn-1' }) const b = await agent.modules.workflow.start({ template_id: 'single', connection_id: 'conn-1' }) expect(a.instanceId).toBe(b.instanceId) @@ -92,7 +95,7 @@ describe('Workflow module', () => { catalog: {}, actions: [], } - await agent.modules.workflow.publishTemplate(tpl as any) + await agent.modules.workflow.publishTemplate(tpl as unknown as WorkflowTemplate) const a = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'A' } }) const b = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'A' } }) const c = await agent.modules.workflow.start({ template_id: 'multi', connection_id: 'c1', context: { k: 'B' } }) @@ -126,7 +129,7 @@ describe('Workflow module', () => { }, ], } - await agent.modules.workflow.publishTemplate(tpl as any) + await agent.modules.workflow.publishTemplate(tpl as unknown as WorkflowTemplate) const inst = await agent.modules.workflow.start({ template_id: 'flow', context: {} }) const s1 = await agent.modules.workflow.status({ instance_id: inst.instanceId }) expect(s1.allowed_events.includes('finish')).toBe(false) diff --git a/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts index c625e45fcc..0fa74357cc 100644 --- a/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts @@ -1,6 +1,9 @@ -import { WorkflowService } from '..' +import type { AgentContext } from '@credo-ts/core' +import { AgentConfig } from 'packages/core/src' +import { WorkflowInstanceRepository, WorkflowModuleConfig, WorkflowService, WorkflowTemplateRepository } from '..' +import type { WorkflowTemplate } from '..' -const baseTpl: any = { +const baseTpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'T', @@ -16,8 +19,10 @@ const baseTpl: any = { describe('sendCompleteMessage via advance to final', () => { test('sends Complete when connectionId present (success path)', async () => { - const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: baseTpl }) } as any - let inst: any = { + const templateRepo = { + findByTemplateIdAndVersion: async () => ({ template: baseTpl }), + } as unknown as WorkflowTemplateRepository + let inst: unknown = { id: 'i', instanceId: 'i', templateId: 't', @@ -32,35 +37,38 @@ describe('sendCompleteMessage via advance to final', () => { const instanceRepo = { getById: async () => inst, getByInstanceId: async () => inst, - update: async (_ctx: any, rec: any) => { + update: async (_ctx: unknown, rec: unknown) => { inst = rec }, - } as any - const config = { guardEngine: 'jmespath', enableProblemReport: true } as any - const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any + } as unknown as WorkflowInstanceRepository + const config = { guardEngine: 'jmespath', enableProblemReport: true } as unknown as WorkflowModuleConfig + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as unknown as AgentConfig const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) // agentContext returns connection service + message sender const connection = { id: 'conn1' } const connectionSvc = { getById: jest.fn(async () => connection) } const messageSender = { sendMessage: jest.fn(async () => {}) } - const agentContext: any = { + const agentContext = { dependencyManager: { - resolve: (ctor: any) => { - if ((ctor?.name || '').includes('ConnectionService')) return connectionSvc - if ((ctor?.name || '').includes('MessageSender')) return messageSender + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' + if (name.includes('ConnectionService')) return connectionSvc + if (name.includes('MessageSender')) return messageSender return {} }, }, - } + } as unknown as AgentContext await svc.advance(agentContext, { instance_id: 'i', event: 'go' }) expect(messageSender.sendMessage).toHaveBeenCalled() }) test('swallows errors during Complete notify (debug logged)', async () => { - const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: baseTpl }) } as any - let inst: any = { + const templateRepo = { + findByTemplateIdAndVersion: async () => ({ template: baseTpl }), + } as unknown as WorkflowTemplateRepository + let inst: unknown = { id: 'i', instanceId: 'i', templateId: 't', @@ -75,12 +83,12 @@ describe('sendCompleteMessage via advance to final', () => { const instanceRepo = { getById: async () => inst, getByInstanceId: async () => inst, - update: async (_ctx: any, rec: any) => { + update: async (_ctx: unknown, rec: unknown) => { inst = rec }, - } as any - const config = { guardEngine: 'jmespath', enableProblemReport: true } as any - const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any + } as unknown as WorkflowInstanceRepository + const config = { guardEngine: 'jmespath', enableProblemReport: true } as unknown as WorkflowModuleConfig + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as unknown as AgentConfig const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) const connectionSvc = { @@ -89,15 +97,16 @@ describe('sendCompleteMessage via advance to final', () => { }), } const messageSender = { sendMessage: jest.fn(async () => {}) } - const agentContext: any = { + const agentContext = { dependencyManager: { - resolve: (ctor: any) => { - if ((ctor?.name || '').includes('ConnectionService')) return connectionSvc - if ((ctor?.name || '').includes('MessageSender')) return messageSender + resolve: (ctor: unknown) => { + const name = (ctor as { name?: string })?.name || '' + if (name.includes('ConnectionService')) return connectionSvc + if (name.includes('MessageSender')) return messageSender return {} }, }, - } + } as unknown as AgentContext await svc.advance(agentContext, { instance_id: 'i', event: 'go' }) // error is swallowed and logged at debug diff --git a/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts index b936a70978..c56f401ad7 100644 --- a/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts @@ -1,8 +1,10 @@ -import { WorkflowService } from '..' +import type { AgentConfig, AgentContext } from '@credo-ts/core' +import { WorkflowModuleConfig, WorkflowService } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' describe('WorkflowService concurrency conflict', () => { test('advance throws state_conflict when state changed concurrently', async () => { - const tpl: any = { + const tpl: WorkflowTemplate = { template_id: 't', version: '1.0.0', title: 'T', @@ -15,8 +17,10 @@ describe('WorkflowService concurrency conflict', () => { catalog: {}, actions: [], } - const tplRepo = { findByTemplateIdAndVersion: async () => ({ template: tpl }) } as any - const inst = { + const tplRepo = { + findByTemplateIdAndVersion: async () => ({ template: tpl }), + } as unknown as import('../repository/WorkflowTemplateRepository').WorkflowTemplateRepository + const inst: WorkflowInstanceData = { id: 'i1', instanceId: 'i1', templateId: 't', @@ -28,26 +32,30 @@ describe('WorkflowService concurrency conflict', () => { status: 'active', history: [], idempotencyKeys: [], - } + } as unknown as WorkflowInstanceData let count = 0 const getById = jest.fn(async () => { count += 1 return count === 2 ? { ...inst, state: 'x' } : inst }) - const instRepo = { getById, getByInstanceId: async () => inst, update: async () => {} } as any + const instRepo = { + getById, + getByInstanceId: async () => inst, + update: async () => {}, + } as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository const svc = new WorkflowService( tplRepo, instRepo, - { + new WorkflowModuleConfig({ guardEngine: 'jmespath', autoReturnExistingOnSingleton: true, actionTimeoutMs: 15000, enableProblemReport: true, - } as any, - { logger: { debug() {}, info() {} } } as any + }), + { logger: { debug() {}, info() {} } } as unknown as AgentConfig ) try { - await svc.advance({} as any, { instance_id: 'i1', event: 'go' }) + await svc.advance({} as unknown as AgentContext, { instance_id: 'i1', event: 'go' }) } catch {} expect(getById).toHaveBeenCalledTimes(2) }) diff --git a/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts index 8469e2f4f2..d0554e69fc 100644 --- a/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts @@ -1,8 +1,12 @@ -import { WorkflowService, WorkflowTemplateRecord } from '..' +import type { AgentConfig, AgentContext, EventEmitter } from '@credo-ts/core' +import { WorkflowModuleConfig, WorkflowService, WorkflowTemplateRecord } from '..' +import type { WorkflowTemplate } from '..' +import type { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' +import type { WorkflowTemplateRepository } from '../repository/WorkflowTemplateRepository' describe('WorkflowService additional coverage', () => { - const makeSvc = (overrides: any = {}) => { - const tpl: any = { + const makeSvc = (overrides: { multiplicity_key?: string; engine?: 'jmespath' | 'js' } = {}) => { + const tpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'T', @@ -16,56 +20,81 @@ describe('WorkflowService additional coverage', () => { actions: [], } const templateRepo = { - findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl }) as WorkflowTemplateRecord), + findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl }) as unknown as WorkflowTemplateRecord), update: jest.fn(), save: jest.fn(), - } as any + } as unknown as WorkflowTemplateRepository + const getById = jest.fn() + const getByInstanceId = jest.fn() + const update = jest.fn() + const save = jest.fn() + const findByTemplateAndConnection = jest.fn(async () => []) + const findByTemplateConnAndMultiplicity = jest.fn(async () => []) + const findLatestByConnection = jest.fn() const instanceRepo = { - getById: jest.fn(), - getByInstanceId: jest.fn(), - update: jest.fn(), - save: jest.fn(), - findByTemplateAndConnection: jest.fn(async () => []), - findByTemplateConnAndMultiplicity: jest.fn(async () => []), - findLatestByConnection: jest.fn(), - } as any - const eventEmitter = { emit: jest.fn() } as any - const config = { + getById, + getByInstanceId, + update, + save, + findByTemplateAndConnection, + findByTemplateConnAndMultiplicity, + findLatestByConnection, + } as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository + const eventEmitter = { emit: jest.fn() } as unknown as EventEmitter + const config = new WorkflowModuleConfig({ guardEngine: overrides.engine || 'jmespath', autoReturnExistingOnSingleton: true, enableProblemReport: true, - } as any - const agentConfig = { logger: { debug: jest.fn(), info() {} } } as any - const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig, eventEmitter) - return { svc, templateRepo, instanceRepo, eventEmitter, agentConfig } + }) + const agentConfig = { logger: { debug: jest.fn(), info() {} } } as unknown as AgentConfig + const svc = new WorkflowService( + templateRepo, + instanceRepo as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository, + config, + agentConfig, + eventEmitter + ) + return { + svc, + templateRepo, + instanceRepo, + eventEmitter, + agentConfig, + getById, + getByInstanceId, + findLatestByConnection, + } } test('pause/resume/cancel emit status-changed and update status', async () => { - const { svc, instanceRepo, eventEmitter } = makeSvc() - const inst: any = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', status: 'active' } - instanceRepo.getById.mockResolvedValue(inst) - await svc.pause({} as any, { instance_id: 'i' }) + const { svc, eventEmitter, getById } = makeSvc() + const inst = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', status: 'active' } + ;(getById as jest.Mock).mockResolvedValue(inst as unknown as WorkflowInstanceRecord) + await svc.pause({} as unknown as AgentContext, { instance_id: 'i' }) expect(inst.status).toBe('paused') expect(eventEmitter.emit).toHaveBeenCalled() inst.status = 'paused' - await svc.resume({} as any, { instance_id: 'i' }) + await svc.resume({} as unknown as AgentContext, { instance_id: 'i' }) expect(inst.status).toBe('active') inst.status = 'active' - await svc.cancel({} as any, { instance_id: 'i' }) + await svc.cancel({} as unknown as AgentContext, { instance_id: 'i' }) expect(inst.status).toBe('canceled') }) test('status throws invalid_event when instance not found', async () => { - const { svc, instanceRepo } = makeSvc() - instanceRepo.getById.mockRejectedValue(new Error('not found')) - instanceRepo.getByInstanceId.mockResolvedValue(null) - await expect(svc.status({} as any, { instance_id: 'missing' })).rejects.toHaveProperty('code', 'invalid_event') + const { svc, getById, getByInstanceId } = makeSvc() + ;(getById as jest.Mock).mockRejectedValue(new Error('not found')) + ;(getByInstanceId as jest.Mock).mockResolvedValue(null) + await expect(svc.status({} as unknown as AgentContext, { instance_id: 'missing' })).rejects.toHaveProperty( + 'code', + 'invalid_event' + ) }) test('status falls back to getByInstanceId when getById fails', async () => { - const { svc, instanceRepo } = makeSvc() + const { svc, getById, getByInstanceId } = makeSvc() const inst = { id: 'i', instanceId: 'i', @@ -78,22 +107,22 @@ describe('WorkflowService additional coverage', () => { artifacts: {}, history: [], } - instanceRepo.getById.mockRejectedValue(new Error('nope')) - instanceRepo.getByInstanceId.mockResolvedValue(inst) - const r = await svc.status({} as any, { instance_id: 'i' }) + ;(getById as jest.Mock).mockRejectedValue(new Error('nope')) + ;(getByInstanceId as jest.Mock).mockResolvedValue(inst) + const r = await svc.status({} as unknown as AgentContext, { instance_id: 'i' }) expect(r.instance_id).toBe('i') }) test('autoAdvanceByConnection swallows errors and logs debug', async () => { - const { svc, instanceRepo, agentConfig } = makeSvc() - instanceRepo.findLatestByConnection.mockResolvedValue({ + const { svc, agentConfig, findLatestByConnection } = makeSvc() + ;(findLatestByConnection as jest.Mock).mockResolvedValue({ instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status: 'completed', }) - await svc.autoAdvanceByConnection({} as any, 'c1', 'request_received') + await svc.autoAdvanceByConnection({} as unknown as AgentContext, 'c1', 'request_received') expect(agentConfig.logger.debug).toHaveBeenCalled() }) @@ -104,29 +133,35 @@ describe('WorkflowService additional coverage', () => { jest.spyOn(Guard, 'evalValue').mockImplementation(() => { throw new Error('bang') }) - let saved: any - instanceRepo.save.mockImplementation(async (_ctx: any, rec: any) => { + let saved: unknown + ;(instanceRepo.save as jest.Mock).mockImplementation(async (_ctx: AgentContext, rec: WorkflowInstanceRecord) => { saved = rec }) - await svc.start({} as any, { template_id: 't', connection_id: 'c1', context: {} }) - expect(saved.multiplicityKeyValue).toBe('') + await svc.start({} as unknown as AgentContext, { template_id: 't', connection_id: 'c1', context: {} }) + expect((saved as { multiplicityKeyValue?: string }).multiplicityKeyValue).toBe('') ;(Guard.evalValue as jest.Mock).mockRestore() }) test('getInstanceByIdOrTag throws invalid_event when neither id nor tag resolves', async () => { - const { svc, instanceRepo } = makeSvc() - instanceRepo.getById.mockRejectedValue(new Error('boom')) - instanceRepo.getByInstanceId.mockResolvedValue(null) + const { svc, getById, getByInstanceId } = makeSvc() + ;(getById as jest.Mock).mockRejectedValue(new Error('boom')) + ;(getByInstanceId as jest.Mock).mockResolvedValue(null) // access private via any and assert thrown - await expect((svc as any).getInstanceByIdOrTag({} as any, 'i')).rejects.toHaveProperty('code', 'invalid_event') + await expect( + ( + svc as unknown as { getInstanceByIdOrTag: (ctx: AgentContext, id: string) => Promise } + ).getInstanceByIdOrTag({} as unknown as AgentContext, 'i') + ).rejects.toHaveProperty('code', 'invalid_event') }) test('getInstanceByIdOrTag returns found on fallback path', async () => { - const { svc, instanceRepo } = makeSvc() + const { svc, getById, getByInstanceId } = makeSvc() const inst = { id: 'i', instanceId: 'i' } - instanceRepo.getById.mockRejectedValue(new Error('boom')) - instanceRepo.getByInstanceId.mockResolvedValue(inst) - const out = await (svc as any).getInstanceByIdOrTag({} as any, 'i') + ;(getById as jest.Mock).mockRejectedValue(new Error('boom')) + ;(getByInstanceId as jest.Mock).mockResolvedValue(inst) + const out = await ( + svc as unknown as { getInstanceByIdOrTag: (ctx: AgentContext, id: string) => Promise<{ instanceId: string }> } + ).getInstanceByIdOrTag({} as unknown as AgentContext, 'i') expect(out.instanceId).toBe('i') }) }) diff --git a/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts index 468bac9fef..b32091cc07 100644 --- a/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts @@ -1,7 +1,9 @@ -import { WorkflowService } from '..' +import type { AgentConfig, AgentContext } from '@credo-ts/core' +import { WorkflowModuleConfig, WorkflowService } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' -const makeSvc = (inst: any) => { - const tpl: any = { +const makeSvc = (inst: WorkflowInstanceData) => { + const tpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'T', @@ -14,50 +16,93 @@ const makeSvc = (inst: any) => { catalog: {}, actions: [], } - const templateRepo = { findByTemplateIdAndVersion: async () => ({ template: tpl }) } as any + const templateRepo = { + findByTemplateIdAndVersion: async () => ({ template: tpl }), + } as unknown as { + findByTemplateIdAndVersion: (ctx: AgentContext, id: string, v?: string) => Promise<{ template: WorkflowTemplate }> + } const instanceRepo = { getById: async () => inst, getByInstanceId: async () => inst, update: async () => {}, - } as any - const config = { + } as unknown as { + getById: (ctx: AgentContext, id: string) => Promise + getByInstanceId: (ctx: AgentContext, id: string) => Promise + update: (ctx: AgentContext, rec: WorkflowInstanceData) => Promise + } + const config = new WorkflowModuleConfig({ guardEngine: 'jmespath', autoReturnExistingOnSingleton: true, actionTimeoutMs: 15000, enableProblemReport: true, - } as any - const agentConfig = { logger: { debug() {}, info() {} } } as any - const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) + }) + const agentConfig = { logger: { debug() {}, info() {} } } as unknown as AgentConfig + const svc = new WorkflowService( + templateRepo as unknown as import('../repository/WorkflowTemplateRepository').WorkflowTemplateRepository, + instanceRepo as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository, + config, + agentConfig + ) return { svc } } describe('WorkflowService lifecycle gating', () => { test('advance forbidden when paused/canceled/completed', async () => { for (const status of ['paused', 'canceled'] as const) { - const inst = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status } + const inst = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status, + } as unknown as WorkflowInstanceData const { svc } = makeSvc(inst) - await expect(svc.advance({} as any, { instance_id: 'i', event: 'go' })).rejects.toHaveProperty( - 'code', - 'forbidden' - ) + await expect( + svc.advance({} as unknown as AgentContext, { instance_id: 'i', event: 'go' }) + ).rejects.toHaveProperty('code', 'forbidden') } // completed → invalid_event - const inst = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status: 'completed' } + const inst = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'completed', + } as unknown as WorkflowInstanceData const { svc } = makeSvc(inst) - await expect(svc.advance({} as any, { instance_id: 'i', event: 'go' })).rejects.toHaveProperty( + await expect(svc.advance({} as unknown as AgentContext, { instance_id: 'i', event: 'go' })).rejects.toHaveProperty( 'code', 'invalid_event' ) }) test('complete only allowed when state is final', async () => { - const inst1 = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'a', status: 'active' } + const inst1 = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'active', + } as unknown as WorkflowInstanceData const { svc: s1 } = makeSvc(inst1) - await expect(s1.complete({} as any, { instance_id: 'i' })).rejects.toHaveProperty('code', 'forbidden') + await expect(s1.complete({} as unknown as AgentContext, { instance_id: 'i' })).rejects.toHaveProperty( + 'code', + 'forbidden' + ) - const inst2 = { id: 'i', instanceId: 'i', templateId: 't', templateVersion: '1', state: 'b', status: 'active' } + const inst2 = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'b', + status: 'active', + } as unknown as WorkflowInstanceData const { svc: s2 } = makeSvc(inst2) - const out = await s2.complete({} as any, { instance_id: 'i' }) + const out = await s2.complete({} as unknown as AgentContext, { instance_id: 'i' }) expect(out.status).toBe('completed') }) }) diff --git a/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts index b27e26ef21..247d7ecf02 100644 --- a/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts @@ -1,4 +1,7 @@ -import { WorkflowInstanceRecord, WorkflowService, WorkflowTemplateRecord } from '..' +import type { AgentConfig, AgentContext } from '@credo-ts/core' +import { WorkflowInstanceRecord, WorkflowModuleConfig, WorkflowService, WorkflowTemplateRecord } from '..' +import type { WorkflowTemplate } from '..' +import type { WorkflowTemplateRepository } from '../repository/WorkflowTemplateRepository' describe('WorkflowService publishTemplate+start edge branches', () => { test('publishTemplate updates existing record (hash + template) and returns it', async () => { @@ -12,21 +15,18 @@ describe('WorkflowService publishTemplate+start edge branches', () => { transitions: [], catalog: {}, actions: [], - } as any, + } as unknown as WorkflowTemplate, }) const templateRepo = { findByTemplateIdAndVersion: jest.fn(async () => existing), update: jest.fn(async () => {}), save: jest.fn(async () => {}), - } as any - const instanceRepo = {} as any - const svc = new WorkflowService( - templateRepo, - instanceRepo, - { guardEngine: 'jmespath' } as any, - { logger: { info() {}, debug() {} } } as any - ) - const nextTpl: any = { + } as unknown as WorkflowTemplateRepository + const instanceRepo = {} as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository + const svc = new WorkflowService(templateRepo, instanceRepo, new WorkflowModuleConfig({ guardEngine: 'jmespath' }), { + logger: { info() {}, debug() {} }, + } as unknown as AgentConfig) + const nextTpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'New', @@ -36,14 +36,14 @@ describe('WorkflowService publishTemplate+start edge branches', () => { catalog: {}, actions: [], } - const rec = await svc.publishTemplate({} as any, nextTpl) + const rec = await svc.publishTemplate({} as unknown as AgentContext, nextTpl) expect(templateRepo.update).toHaveBeenCalled() expect(rec.template.title).toBe('New') expect(rec.hash).toBeDefined() }) test('start singleton_per_connection without autoReturnExistingOnSingleton throws already_exists', async () => { - const tpl: any = { + const tpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'T', @@ -53,7 +53,9 @@ describe('WorkflowService publishTemplate+start edge branches', () => { catalog: {}, actions: [], } - const templateRepo = { findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl })) } as any + const templateRepo = { + findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl }) as unknown as WorkflowTemplateRecord), + } as unknown as WorkflowTemplateRepository const existing = new WorkflowInstanceRecord({ instanceId: 'i', templateId: 't', @@ -65,16 +67,17 @@ describe('WorkflowService publishTemplate+start edge branches', () => { status: 'active', history: [], }) - const instanceRepo = { findByTemplateAndConnection: jest.fn(async () => [existing]) } as any + const instanceRepo = { + findByTemplateAndConnection: jest.fn(async () => [existing]), + } as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository const svc = new WorkflowService( templateRepo, instanceRepo, - { guardEngine: 'jmespath', autoReturnExistingOnSingleton: false } as any, - { logger: { info() {}, debug() {} } } as any - ) - await expect(svc.start({} as any, { template_id: 't', connection_id: 'c1' })).rejects.toHaveProperty( - 'code', - 'already_exists' + new WorkflowModuleConfig({ guardEngine: 'jmespath', autoReturnExistingOnSingleton: false }), + { logger: { info() {}, debug() {} } } as unknown as AgentConfig ) + await expect( + svc.start({} as unknown as AgentContext, { template_id: 't', connection_id: 'c1' }) + ).rejects.toHaveProperty('code', 'already_exists') }) }) diff --git a/packages/workflow/src/tests/WorkflowService.status-options.spec.ts b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts index dc96921fdb..1faaee5094 100644 --- a/packages/workflow/src/tests/WorkflowService.status-options.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts @@ -1,8 +1,14 @@ +import type { AgentConfig, AgentContext } from '@credo-ts/core' import { WorkflowService } from '..' +import type { WorkflowTemplate } from '..' +import { WorkflowModuleConfig } from '..' +import type { WorkflowInstanceRecord } from '../repository/WorkflowInstanceRecord' +import type { WorkflowTemplateRecord } from '../repository/WorkflowTemplateRecord' +import type { WorkflowTemplateRepository } from '../repository/WorkflowTemplateRepository' describe('WorkflowService.status include flags', () => { const make = () => { - const tpl: any = { + const tpl: WorkflowTemplate = { template_id: 't', version: '1', title: 'T', @@ -21,8 +27,10 @@ describe('WorkflowService.status include flags', () => { catalog: {}, actions: [], } - const templateRepo = { findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl })) } as any - const inst: any = { + const templateRepo = { + findByTemplateIdAndVersion: jest.fn(async () => ({ template: tpl }) as unknown as WorkflowTemplateRecord), + } as unknown as WorkflowTemplateRepository + const inst = { id: 'i', instanceId: 'i', templateId: 't', @@ -33,25 +41,36 @@ describe('WorkflowService.status include flags', () => { artifacts: {}, history: [], status: 'active', - } - const instanceRepo = { getById: jest.fn(async () => inst), update: jest.fn() } as any - const config = { guardEngine: 'jmespath', enableProblemReport: true } as any - const agentConfig = { logger: { debug() {}, info() {} } } as any + } as unknown as WorkflowInstanceRecord + const instanceRepo = { + getById: jest.fn(async () => inst), + update: jest.fn(), + } as unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository + const config = new WorkflowModuleConfig({ guardEngine: 'jmespath', enableProblemReport: true }) + const agentConfig = { logger: { debug() {}, info() {} } } as unknown as AgentConfig const svc = new WorkflowService(templateRepo, instanceRepo, config, agentConfig) return { svc } } test('include_actions=false include_ui=true', async () => { const { svc } = make() - const r = await svc.status({} as any, { instance_id: 'i', include_actions: false, include_ui: true }) + const r = await svc.status({} as unknown as AgentContext, { + instance_id: 'i', + include_actions: false, + include_ui: true, + }) expect(r.action_menu).toEqual([]) expect(Array.isArray(r.ui)).toBe(true) }) test('include_actions=true include_ui=false', async () => { const { svc } = make() - const r = await svc.status({} as any, { instance_id: 'i', include_actions: true, include_ui: false }) + const r = await svc.status({} as unknown as AgentContext, { + instance_id: 'i', + include_actions: true, + include_ui: false, + }) expect(r.action_menu.map((i) => i.event)).toEqual(expect.arrayContaining(['go', 'send'])) - expect((r as any).ui).toBeUndefined() + expect(r.ui).toBeUndefined() }) }) diff --git a/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts index 7bcff89872..ae07d1e379 100644 --- a/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts +++ b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts @@ -1,24 +1,26 @@ -import { WorkflowService, validateTemplateJson } from '..' +import type { AgentConfig } from '@credo-ts/core' +import { WorkflowModuleConfig, WorkflowService, validateTemplateJson, validateTemplateRefs } from '..' +import type { WorkflowInstanceRepository } from '../repository/WorkflowInstanceRepository' +import type { WorkflowTemplateRepository } from '../repository/WorkflowTemplateRepository' describe('WorkflowService.validateTemplate (private)', () => { - const make = () => { - const tplRepo = {} as any - const instRepo = {} as any - const config = { guardEngine: 'jmespath', enableProblemReport: true } as any - const agentConfig = { logger: { debug() {}, info() {} } } as any + const _make = () => { + const tplRepo = {} as unknown as WorkflowTemplateRepository + const instRepo = {} as unknown as WorkflowInstanceRepository + const config = new WorkflowModuleConfig({ guardEngine: 'jmespath', enableProblemReport: true }) + const agentConfig = { logger: { debug() {}, info() {} } } as unknown as AgentConfig const svc = new WorkflowService(tplRepo, instRepo, config, agentConfig) - return svc as any + return svc as unknown as WorkflowService } test('valid template passes', () => { - const svc = make() const tpl = { template_id: 't', version: '1', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, + instance_policy: { mode: 'multi_per_connection' as const }, sections: [{ name: 'Main' }], - states: [{ name: 's', type: 'start', section: 'Main' }], + states: [{ name: 's', type: 'start' as const, section: 'Main' }], transitions: [{ from: 's', to: 's', on: 'x' }], catalog: { credential_profiles: { @@ -31,53 +33,53 @@ describe('WorkflowService.validateTemplate (private)', () => { { key: 'b', typeURI: 'https://didcomm.org/present-proof/2.0/request-presentation', profile_ref: 'pp.p' }, ], } - expect(() => svc.validateTemplate(tpl)).not.toThrow() + expect(() => validateTemplateJson(tpl)).not.toThrow() + expect(() => validateTemplateRefs(tpl as unknown as import('..').WorkflowTemplate)).not.toThrow() }) test('invalid transitions and actions throw', () => { - const svc = make() const bad1 = { template_id: 't', version: '1', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, - states: [{ name: 's', type: 'start' }], + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], transitions: [{ from: 'unknown', to: 's', on: 'x' }], catalog: {}, actions: [], } - expect(() => svc.validateTemplate(bad1)).toThrow() + expect(() => validateTemplateRefs(bad1 as unknown as import('..').WorkflowTemplate)).toThrow() const bad2 = { template_id: 't', version: '1', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, - states: [{ name: 's', type: 'start' }], + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], transitions: [{ from: 's', to: 's', on: 'x', action: 'missing' }], catalog: {}, actions: [], } - expect(() => svc.validateTemplate(bad2)).toThrow() + expect(() => validateTemplateRefs(bad2 as unknown as import('..').WorkflowTemplate)).toThrow() const bad3 = { template_id: 't', version: '1', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, - states: [{ name: 's', type: 'start' }], + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], transitions: [], catalog: {}, actions: [{ key: 'a', typeURI: 'x', profile_ref: 'zz.test' }], } - expect(() => svc.validateTemplate(bad3)).toThrow() + expect(() => validateTemplateRefs(bad3 as unknown as import('..').WorkflowTemplate)).toThrow() }) test('validateTemplateJson rejects actions missing key', () => { - const bad: any = { + const bad = { template_id: 't', version: '1', title: 'T', - instance_policy: { mode: 'multi_per_connection' }, - states: [{ name: 's', type: 'start' }], + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], transitions: [], catalog: {}, actions: [{ typeURI: 'x' }], diff --git a/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts index 6fd6582434..628cf79535 100644 --- a/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts +++ b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts @@ -1,15 +1,25 @@ +import type { AgentContext, EventEmitter, StorageService } from '@credo-ts/core' import { WorkflowTemplateRepository } from '..' +import type { WorkflowTemplateRecord } from '../repository/WorkflowTemplateRecord' describe('WorkflowTemplateRepository findByTemplateIdAndVersion', () => { test('chooses highest version when no version specified', async () => { - const repo = new WorkflowTemplateRepository({} as any, { on: () => {} } as any) - const list = [ + const repo = new WorkflowTemplateRepository( + {} as unknown as StorageService, + { on: () => {} } as unknown as EventEmitter + ) + const list: Array<{ template: { template_id: string; version: string } }> = [ { template: { template_id: 't', version: '1.0.0' } }, { template: { template_id: 't', version: '1.2.0' } }, { template: { template_id: 't', version: '1.10.0' } }, - ] as any - jest.spyOn(repo as any, 'findByQuery').mockResolvedValue(list) - const res = await repo.findByTemplateIdAndVersion({} as any, 't') + ] + jest + .spyOn( + repo as unknown as { findByQuery: (ctx: AgentContext, q: Record) => Promise }, + 'findByQuery' + ) + .mockResolvedValue(list) + const res = await repo.findByTemplateIdAndVersion({} as unknown as AgentContext, 't') // Sorting is lexicographic in repository implementation expect(res?.template.version).toBe('1.2.0') }) From d572d4c199e7ded8e4d917ddf9d694c38235914c Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 10:39:29 -0400 Subject: [PATCH 17/20] chore(workflow): satisfy Biome static- only class lint in engine - Add targeted biome-ignore to GuardEvaluator and AttributePlanner utility classes (no logic change). Signed-off-by: Vinay Singh --- packages/workflow/src/engine/GuardEvaluator.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/workflow/src/engine/GuardEvaluator.ts b/packages/workflow/src/engine/GuardEvaluator.ts index e0a10a8bbc..bd2b6c4d4a 100644 --- a/packages/workflow/src/engine/GuardEvaluator.ts +++ b/packages/workflow/src/engine/GuardEvaluator.ts @@ -7,20 +7,23 @@ export type GuardEnv = { artifacts: Record } +// biome-ignore lint/complexity/noStaticOnlyClass: Utility holder class for guard evaluation export class GuardEvaluator { public static evalGuard(expression: string | undefined, env: GuardEnv): boolean { if (!expression) return true try { - const res = jmespath.search(env as any, expression) + const jpSearch = jmespath.search as unknown as (obj: unknown, expr: string) => unknown + const res = jpSearch(env, expression) return !!res } catch { return false } } - public static evalValue(expression: string, env: GuardEnv): any { + public static evalValue(expression: string, env: GuardEnv): unknown { try { - return jmespath.search(env as any, expression) + const jpSearch = jmespath.search as unknown as (obj: unknown, expr: string) => unknown + return jpSearch(env, expression) } catch { return undefined } From a3c71e3671966041a6fa3748c4ce2bd825072159 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 10:40:15 -0400 Subject: [PATCH 18/20] fix(workflow): validation for profile_ref; action message-id resolution - Reject invalid action.profile_ref values (must be cp.* or pp.*). - Prefer found.message.id, fallback to found.id or record id. Signed-off-by: Vinay Singh --- .../workflow/src/model/TemplateValidation.ts | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/workflow/src/model/TemplateValidation.ts b/packages/workflow/src/model/TemplateValidation.ts index c3d446c0b0..a1dd0217de 100644 --- a/packages/workflow/src/model/TemplateValidation.ts +++ b/packages/workflow/src/model/TemplateValidation.ts @@ -5,7 +5,7 @@ import type { WorkflowTemplate } from './types' const ajv = new Ajv({ allErrors: true, strict: false }) addFormats(ajv) -const schema: any = { +const schema = { type: 'object', required: ['template_id', 'version', 'title', 'instance_policy', 'states', 'transitions', 'catalog', 'actions'], properties: { @@ -155,14 +155,15 @@ const schema: any = { additionalProperties: false, } -const validate = ajv.compile(schema) +const validate = ajv.compile(schema as unknown as object) export function validateTemplateJson(tpl: unknown) { const ok = validate(tpl) if (!ok) { - const msg = (validate.errors || []).map((e: any) => `${e.instancePath || 'template'} ${e.message}`).join('; ') - const err = new Error(msg) - ;(err as any).code = 'invalid_template' + const errs = (validate.errors || []) as Array<{ instancePath?: string; message?: string }> + const msg = errs.map((e) => `${e.instancePath || 'template'} ${e.message}`).join('; ') + const err = new Error(msg) as Error & { code: string } + err.code = 'invalid_template' throw err } } @@ -171,51 +172,55 @@ export function validateTemplateRefs(t: WorkflowTemplate) { // Structural checks beyond schema const stateNames = new Set(t.states.map((s) => s.name)) if (![...t.states].some((s) => s.type === 'start')) { - const err = new Error('start state required') - ;(err as any).code = 'invalid_template' + const err = new Error('start state required') as Error & { code?: string } + err.code = 'invalid_template' throw err } for (const s of t.states) { if (s.section && !t.sections?.some((sec) => sec.name === s.section)) { - const err = new Error(`state.section not found: ${s.section}`) - ;(err as any).code = 'invalid_template' + const err = new Error(`state.section not found: ${s.section}`) as Error & { code?: string } + err.code = 'invalid_template' throw err } } for (const tr of t.transitions) { if (!stateNames.has(tr.from)) { - const err = new Error(`transition.from unknown: ${tr.from}`) - ;(err as any).code = 'invalid_template' + const err = new Error(`transition.from unknown: ${tr.from}`) as Error & { code?: string } + err.code = 'invalid_template' throw err } if (!stateNames.has(tr.to)) { - const err = new Error(`transition.to unknown: ${tr.to}`) - ;(err as any).code = 'invalid_template' + const err = new Error(`transition.to unknown: ${tr.to}`) as Error & { code?: string } + err.code = 'invalid_template' throw err } if (tr.action && !t.actions.some((a) => a.key === tr.action)) { - const err = new Error(`transition.action unknown: ${tr.action}`) - ;(err as any).code = 'invalid_template' + const err = new Error(`transition.action unknown: ${tr.action}`) as Error & { code?: string } + err.code = 'invalid_template' throw err } } for (const a of t.actions) { - const pr: any = (a as any).profile_ref - if (pr) { + if ('profile_ref' in a) { + const pr = (a as { profile_ref: string }).profile_ref if (pr.startsWith('cp.')) { const key = pr.slice(3) if (!t.catalog?.credential_profiles || !t.catalog.credential_profiles[key]) { const err = new Error(`catalog.cp missing: ${key}`) - ;(err as any).code = 'invalid_template' + ;(err as Error & { code?: string }).code = 'invalid_template' throw err } } else if (pr.startsWith('pp.')) { const key = pr.slice(3) if (!t.catalog?.proof_profiles || !t.catalog.proof_profiles[key]) { const err = new Error(`catalog.pp missing: ${key}`) - ;(err as any).code = 'invalid_template' + ;(err as Error & { code?: string }).code = 'invalid_template' throw err } + } else if (typeof pr === 'string') { + const err = new Error(`invalid profile_ref: ${pr}`) + ;(err as Error & { code?: string }).code = 'invalid_template' + throw err } } } From 4e93f29c2b8d7ce7e4ada4f3537b7e1be0045b33 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 10:54:02 -0400 Subject: [PATCH 19/20] feat(workflow): add Workflow 1.0 protocol over DIDComm v2 Signed-off-by: Vinay Singh --- .../workflow/src/actions/ActionRegistry.ts | 104 +++++++++++------- packages/workflow/src/api/WorkflowApi.ts | 4 +- .../workflow/src/engine/AttributePlanner.ts | 54 +++++---- packages/workflow/src/model/types.ts | 15 ++- .../src/protocol/handlers/AdvanceHandler.ts | 2 +- .../src/protocol/handlers/CancelHandler.ts | 4 +- .../src/protocol/handlers/CompleteHandler.ts | 4 +- .../src/protocol/handlers/PauseHandler.ts | 4 +- .../handlers/PublishTemplateHandler.ts | 2 +- .../src/protocol/handlers/ResumeHandler.ts | 4 +- .../src/protocol/handlers/StartHandler.ts | 2 +- .../src/protocol/handlers/StatusHandler.ts | 2 +- .../protocol/messages/ProblemReportMessage.ts | 2 +- .../src/protocol/messages/StatusMessage.ts | 2 +- .../src/repository/WorkflowInstanceRecord.ts | 11 +- .../repository/WorkflowInstanceRepository.ts | 20 +++- .../repository/WorkflowTemplateRepository.ts | 12 +- .../workflow/src/services/WorkflowService.ts | 36 +++--- 18 files changed, 179 insertions(+), 105 deletions(-) diff --git a/packages/workflow/src/actions/ActionRegistry.ts b/packages/workflow/src/actions/ActionRegistry.ts index 6c760ac2e4..b5348e71a4 100644 --- a/packages/workflow/src/actions/ActionRegistry.ts +++ b/packages/workflow/src/actions/ActionRegistry.ts @@ -7,7 +7,7 @@ export type ActionCtx = { template: WorkflowTemplate instance: WorkflowInstanceData action: ActionDef - input?: any + input?: Record } export type ActionResult = { @@ -34,22 +34,26 @@ export class ActionRegistry { export class LocalStateSetAction implements WorkflowActionHandler { public readonly typeUri = 'https://didcomm.org/workflow/actions/state:set@1' public async execute(ctx: ActionCtx): Promise { - const anyAct: any = ctx.action - const mergeObj = anyAct?.staticInput?.merge - if (mergeObj && typeof mergeObj === 'object') { - const next = deepMerge({ ...(ctx.instance.context || {}) }, mergeObj) + const mergeObj = (ctx.action as { staticInput?: unknown })?.staticInput as { merge?: unknown } | undefined | string + const mergeValue = typeof mergeObj === 'object' && mergeObj ? (mergeObj as { merge?: unknown }).merge : mergeObj + if (mergeValue && typeof mergeValue === 'object') { + const next = deepMerge({ ...(ctx.instance.context || {}) }, mergeValue as Record) return { contextMerge: next } } // if string with template, try basic input resolution: '{{ input.form }}' - if (typeof mergeObj === 'string' && ctx.input && mergeObj.includes('input.')) { + if (typeof mergeValue === 'string' && ctx.input && mergeValue.includes('input.')) { try { - const path = mergeObj + const path = mergeValue .replace(/\{\{|\}\}/g, '') .trim() .replace(/^input\./, '') - const value = path.split('.').reduce((acc: any, p: string) => (acc == null ? undefined : acc[p]), ctx.input) + const value = path.split('.').reduce((acc, p) => { + if (acc === null || acc === undefined) return undefined + if (typeof acc !== 'object') return undefined + return (acc as Record)[p] + }, ctx.input) if (value && typeof value === 'object') { - const next = deepMerge({ ...(ctx.instance.context || {}) }, value) + const next = deepMerge({ ...(ctx.instance.context || {}) }, value as Record) return { contextMerge: next } } } catch {} @@ -61,7 +65,7 @@ export class LocalStateSetAction implements WorkflowActionHandler { export class IssueCredentialV2Action implements WorkflowActionHandler { public readonly typeUri = 'https://didcomm.org/issue-credential/2.0/offer-credential' public async execute(ctx: ActionCtx): Promise { - const act: any = ctx.action + const act = ctx.action as { profile_ref: string } const ref: string = act.profile_ref if (!ref?.startsWith('cp.')) throw Object.assign(new Error('invalid profile_ref'), { code: 'action_error' }) const key = ref.slice(3) @@ -79,26 +83,36 @@ export class IssueCredentialV2Action implements WorkflowActionHandler { const { DidCommConnectionService, } = require('@credo-ts/didcomm/src/modules/connections/services/DidCommConnectionService') - const connSvc: any = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) + const connSvc = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) as import( + '@credo-ts/didcomm' + ).DidCommConnectionService const conn = await connSvc.getById(ctx.agentContext, ctx.instance.connection_id) - const theirDid = (conn as any)?.theirDid + const theirDid = (conn as unknown as { theirDid?: string })?.theirDid if (theirDid && theirDid !== expectedDid) throw Object.assign(new Error('to_ref DID mismatch'), { code: 'forbidden' }) } } try { const { DidCommCredentialsApi } = require('@credo-ts/didcomm/src/modules/credentials/DidCommCredentialsApi') - const credsApi: any = ctx.agentContext.dependencyManager.resolve(DidCommCredentialsApi) + const credsApi = ctx.agentContext.dependencyManager.resolve(DidCommCredentialsApi) as unknown as { + offerCredential: (options: unknown) => Promise<{ id: string; credentialRecord?: { id?: string } }> + findOfferMessage: (id: string) => Promise + } const record = await credsApi.offerCredential({ connectionId, protocolVersion: 'v2', credentialFormats: { anoncreds: { credentialDefinitionId: profile.cred_def_id, attributes } }, comment: profile.options?.comment, - } as any) - let messageId = record?.id || record?.credentialRecord?.id + }) + let messageId: string | undefined = record?.id || record?.credentialRecord?.id try { - const found = await credsApi.findOfferMessage(messageId) - messageId = found?.message?.id || messageId + if (messageId) { + const found = (await credsApi.findOfferMessage(messageId)) as unknown + if (found && typeof found === 'object') { + const f = found as { id?: string; message?: { id?: string } } + messageId = f.message?.id || f.id || messageId + } + } } catch {} return { artifacts: { issueRecordId: record?.id || record?.credentialRecord?.id }, messageId } } catch (e) { @@ -110,7 +124,7 @@ export class IssueCredentialV2Action implements WorkflowActionHandler { export class PresentProofV2Action implements WorkflowActionHandler { public readonly typeUri = 'https://didcomm.org/present-proof/2.0/request-presentation' public async execute(ctx: ActionCtx): Promise { - const act: any = ctx.action + const act = ctx.action as { profile_ref: string } const ref: string = act.profile_ref if (!ref?.startsWith('pp.')) throw Object.assign(new Error('invalid profile_ref'), { code: 'action_error' }) const key = ref.slice(3) @@ -126,30 +140,41 @@ export class PresentProofV2Action implements WorkflowActionHandler { const { DidCommConnectionService, } = require('@credo-ts/didcomm/src/modules/connections/services/DidCommConnectionService') - const connSvc: any = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) + const connSvc = ctx.agentContext.dependencyManager.resolve(DidCommConnectionService) as import( + '@credo-ts/didcomm' + ).DidCommConnectionService const conn = await connSvc.getById(ctx.agentContext, ctx.instance.connection_id) - const theirDid = (conn as any)?.theirDid + const theirDid = (conn as unknown as { theirDid?: string })?.theirDid if (theirDid && theirDid !== expectedDid2) throw Object.assign(new Error('to_ref DID mismatch'), { code: 'forbidden' }) } } try { const { DidCommProofsApi } = require('@credo-ts/didcomm/src/modules/proofs/DidCommProofsApi') - const proofsApi: any = ctx.agentContext.dependencyManager.resolve(DidCommProofsApi) - const credDefId = (profile as any).cred_def_id - const schemaId = (profile as any).schema_id + const proofsApi = ctx.agentContext.dependencyManager.resolve(DidCommProofsApi) as unknown as { + requestProof: (options: unknown) => Promise<{ id: string; proofRecord?: { id?: string } }> + findRequestMessage: (id: string) => Promise + } + const credDefId = (profile as { cred_def_id?: string }).cred_def_id + const schemaId = (profile as { schema_id?: string }).schema_id const restriction = credDefId ? { cred_def_id: credDefId } : schemaId ? { schema_id: schemaId } : undefined - const reqAttrs = (profile.requested_attributes || []).reduce((acc: any, name: string, idx: number) => { - acc[`attr${idx + 1}`] = restriction ? { name, restrictions: [restriction] } : { name } - return acc - }, {}) - const reqPreds = (profile.requested_predicates || []).reduce((acc: any, p: any, idx: number) => { - acc[`pred${idx + 1}`] = restriction - ? { name: p.name, p_type: p.p_type, p_value: p.p_value, restrictions: [restriction] } - : { name: p.name, p_type: p.p_type, p_value: p.p_value } - return acc - }, {}) + const reqAttrs = (profile.requested_attributes || []).reduce>( + (acc, name: string, idx: number) => { + acc[`attr${idx + 1}`] = restriction ? { name, restrictions: [restriction] } : { name } + return acc + }, + {} + ) + const reqPreds = (profile.requested_predicates || []).reduce>( + (acc, p: { name: string; p_type: string; p_value: number }, idx: number) => { + acc[`pred${idx + 1}`] = restriction + ? { name: p.name, p_type: p.p_type, p_value: p.p_value, restrictions: [restriction] } + : { name: p.name, p_type: p.p_type, p_value: p.p_value } + return acc + }, + {} + ) const record = await proofsApi.requestProof({ connectionId, protocolVersion: 'v2', @@ -163,11 +188,16 @@ export class PresentProofV2Action implements WorkflowActionHandler { }, willConfirm: true, comment: profile.options?.comment, - } as any) - let messageId = record?.id || record?.proofRecord?.id + }) + let messageId: string | undefined = record?.id || record?.proofRecord?.id try { - const found = await proofsApi.findRequestMessage(messageId) - messageId = found?.message?.id || messageId + if (messageId) { + const found = (await proofsApi.findRequestMessage(messageId)) as unknown + if (found && typeof found === 'object') { + const f = found as { id?: string; message?: { id?: string } } + messageId = f.message?.id || f.id || messageId + } + } } catch {} return { artifacts: { proofRecordId: record?.id || record?.proofRecord?.id }, messageId } } catch (e) { diff --git a/packages/workflow/src/api/WorkflowApi.ts b/packages/workflow/src/api/WorkflowApi.ts index aa1b06a27f..703f53aa1c 100644 --- a/packages/workflow/src/api/WorkflowApi.ts +++ b/packages/workflow/src/api/WorkflowApi.ts @@ -30,7 +30,7 @@ export class WorkflowApi { instance_id: string event: string idempotency_key?: string - input?: any + input?: Record }): Promise { return this.service.advance(this.agentContext, opts) } @@ -42,7 +42,7 @@ export class WorkflowApi { allowed_events: string[] action_menu: Array<{ label?: string; event: string }> artifacts: Record - ui?: any[] + ui?: import('../model/types').UiItem[] }> { return this.service.status(this.agentContext, opts) } diff --git a/packages/workflow/src/engine/AttributePlanner.ts b/packages/workflow/src/engine/AttributePlanner.ts index bc1dfcc265..4bf1d2abfb 100644 --- a/packages/workflow/src/engine/AttributePlanner.ts +++ b/packages/workflow/src/engine/AttributePlanner.ts @@ -1,34 +1,46 @@ import jmespath from 'jmespath' import { AttributeSpec, WorkflowInstanceData } from '../model/types' -const isObject = (v: any) => v && typeof v === 'object' && !Array.isArray(v) +const isObject = (v: unknown): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v) +// biome-ignore lint/complexity/noStaticOnlyClass: Utility holder class for attribute planning export class AttributePlanner { - public static materialize(plan: Record, instance: WorkflowInstanceData): Record { - const out: Record = {} + public static materialize( + plan: Record, + instance: WorkflowInstanceData + ): Record { + const out: Record = {} for (const [key, spec] of Object.entries(plan || {})) { - let value: any - if ((spec as any).source === 'context') { - const p = (spec as any).path as string - value = AttributePlanner.getByPath(instance.context || {}, p) - } else if ((spec as any).source === 'static') { - value = (spec as any).value - } else if ((spec as any).source === 'compute') { - value = AttributePlanner.computeExpr((spec as any).expr, instance) + let value: unknown + if (spec.source === 'context') { + const p = spec.path + value = AttributePlanner.getByPath((instance.context || {}) as Record, p) + } else if (spec.source === 'static') { + value = spec.value + } else if (spec.source === 'compute') { + value = AttributePlanner.computeExpr(spec.expr, instance) } - if ((spec as any).required && (value === undefined || value === null || value === '')) { - throw Object.assign(new Error('missing_attributes'), { code: 'missing_attributes', attribute: key }) + if (spec.required && (value === undefined || value === null || value === '')) { + const err = new Error('missing_attributes') as Error & { code: string; attribute: string } + err.code = 'missing_attributes' + err.attribute = key + throw err } if (value !== undefined) out[key] = value } return out } - private static getByPath(obj: any, path: string) { - return path.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), obj) + private static getByPath(obj: Record, path: string): unknown { + return path.split('.').reduce((acc, part) => { + if (acc === null || acc === undefined) return undefined + if (typeof acc !== 'object') return undefined + const rec = acc as Record + return rec[part] + }, obj) } - private static computeExpr(expr: string, instance: WorkflowInstanceData): any { + private static computeExpr(expr: string, instance: WorkflowInstanceData): unknown { // Evaluate compute expressions using JMESPath over a pure env // Expose a stable 'now' value as an ISO string for this evaluation const env = { @@ -38,19 +50,23 @@ export class AttributePlanner { now: new Date().toISOString(), } try { - return jmespath.search(env as any, expr) + const jpSearch = jmespath.search as unknown as (obj: unknown, expr: string) => unknown + return jpSearch(env, expr) } catch { return undefined } } } -export const deepMerge = (target: any, source: any) => { +export const deepMerge = ( + target: Record, + source: Record +): Record => { if (!isObject(target) || !isObject(source)) return source for (const [k, v] of Object.entries(source)) { if (isObject(v)) { if (!isObject(target[k])) target[k] = {} - deepMerge(target[k], v) + deepMerge(target[k] as Record, v) } else { target[k] = v } diff --git a/packages/workflow/src/model/types.ts b/packages/workflow/src/model/types.ts index f570ffac1f..53fcf9d3aa 100644 --- a/packages/workflow/src/model/types.ts +++ b/packages/workflow/src/model/types.ts @@ -45,10 +45,17 @@ export type Catalog = { export type ActionDef = | { key: string; typeURI: string; profile_ref: string } - | { key: string; typeURI: string; staticInput?: any } + | { key: string; typeURI: string; staticInput?: unknown } + +export type UiItem = { + type?: string + label?: string + event?: string + [key: string]: unknown +} export type DisplayHints = { - states?: Record + states?: Record } export type WorkflowTemplate = { @@ -83,8 +90,8 @@ export type WorkflowInstanceData = { participants: Participants state: string section?: string - context: Record - artifacts: Record + context: Record + artifacts: Record status: 'active' | 'paused' | 'canceled' | 'completed' | 'error' history: InstanceHistoryItem[] multiplicityKeyValue?: string diff --git a/packages/workflow/src/protocol/handlers/AdvanceHandler.ts b/packages/workflow/src/protocol/handlers/AdvanceHandler.ts index bfa7184b9a..3b60c262e1 100644 --- a/packages/workflow/src/protocol/handlers/AdvanceHandler.ts +++ b/packages/workflow/src/protocol/handlers/AdvanceHandler.ts @@ -41,7 +41,7 @@ export class AdvanceHandler implements DidCommMessageHandler { if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/CancelHandler.ts b/packages/workflow/src/protocol/handlers/CancelHandler.ts index a7b272eb56..d74f3b8a13 100644 --- a/packages/workflow/src/protocol/handlers/CancelHandler.ts +++ b/packages/workflow/src/protocol/handlers/CancelHandler.ts @@ -31,14 +31,14 @@ export class CancelHandler implements DidCommMessageHandler { connection: messageContext.connection, }) } catch (e) { - if ((e as any)?.code === 'invalid_event') { + if ((e as { code?: string })?.code === 'invalid_event') { logger.info('[Workflow] cancel ignored (no local instance)', { instance_id: instId }) return undefined } if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/CompleteHandler.ts b/packages/workflow/src/protocol/handlers/CompleteHandler.ts index 4aee7915eb..689926dbde 100644 --- a/packages/workflow/src/protocol/handlers/CompleteHandler.ts +++ b/packages/workflow/src/protocol/handlers/CompleteHandler.ts @@ -28,14 +28,14 @@ export class CompleteHandler implements DidCommMessageHandler { }) } catch (e) { // If the receiving agent doesn't host the instance, ignore silently (no problem-report) - if ((e as any)?.code === 'invalid_event') { + if ((e as { code?: string })?.code === 'invalid_event') { logger.info('[Workflow] complete ignored (no local instance)', { instance_id: instId }) return undefined } if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/PauseHandler.ts b/packages/workflow/src/protocol/handlers/PauseHandler.ts index 85b88ce2e1..531012e8b1 100644 --- a/packages/workflow/src/protocol/handlers/PauseHandler.ts +++ b/packages/workflow/src/protocol/handlers/PauseHandler.ts @@ -31,14 +31,14 @@ export class PauseHandler implements DidCommMessageHandler { connection: messageContext.connection, }) } catch (e) { - if ((e as any)?.code === 'invalid_event') { + if ((e as { code?: string })?.code === 'invalid_event') { logger.info('[Workflow] pause ignored (no local instance)', { instance_id: instId }) return undefined } if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts b/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts index ac42b58c16..56a3afa0d8 100644 --- a/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts +++ b/packages/workflow/src/protocol/handlers/PublishTemplateHandler.ts @@ -22,7 +22,7 @@ export class PublishTemplateHandler implements DidCommMessageHandler { if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/ResumeHandler.ts b/packages/workflow/src/protocol/handlers/ResumeHandler.ts index 400d4b97f4..40ae87b3e1 100644 --- a/packages/workflow/src/protocol/handlers/ResumeHandler.ts +++ b/packages/workflow/src/protocol/handlers/ResumeHandler.ts @@ -31,14 +31,14 @@ export class ResumeHandler implements DidCommMessageHandler { connection: messageContext.connection, }) } catch (e) { - if ((e as any)?.code === 'invalid_event') { + if ((e as { code?: string })?.code === 'invalid_event') { logger.info('[Workflow] resume ignored (no local instance)', { instance_id: instId }) return undefined } if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/StartHandler.ts b/packages/workflow/src/protocol/handlers/StartHandler.ts index 3526ce6b04..5e4afb15cb 100644 --- a/packages/workflow/src/protocol/handlers/StartHandler.ts +++ b/packages/workflow/src/protocol/handlers/StartHandler.ts @@ -44,7 +44,7 @@ export class StartHandler implements DidCommMessageHandler { if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/handlers/StatusHandler.ts b/packages/workflow/src/protocol/handlers/StatusHandler.ts index d9952012d9..66b42689fa 100644 --- a/packages/workflow/src/protocol/handlers/StatusHandler.ts +++ b/packages/workflow/src/protocol/handlers/StatusHandler.ts @@ -48,7 +48,7 @@ export class StatusHandler implements DidCommMessageHandler { if (config.enableProblemReport && messageContext.connection) { const pr = new ProblemReportMessage({ thid: messageContext.message.threadId || messageContext.message.id, - body: { code: (e as any).code || 'action_error', comment: (e as Error).message }, + body: { code: (e as { code?: string }).code || 'action_error', comment: (e as Error).message }, }) return new DidCommOutboundMessageContext(pr, { agentContext: messageContext.agentContext, diff --git a/packages/workflow/src/protocol/messages/ProblemReportMessage.ts b/packages/workflow/src/protocol/messages/ProblemReportMessage.ts index 85c328ff7b..571bc826de 100644 --- a/packages/workflow/src/protocol/messages/ProblemReportMessage.ts +++ b/packages/workflow/src/protocol/messages/ProblemReportMessage.ts @@ -6,7 +6,7 @@ export class ProblemReportMessage extends DidCommMessage { @IsValidMessageType(ProblemReportMessage.type) public type = ProblemReportMessage.type.messageTypeUri - public body!: { code: string; comment?: string; args?: any } + public body!: { code: string; comment?: string; args?: Record } public constructor(options?: { id?: string; body: ProblemReportMessage['body']; thid?: string }) { super() diff --git a/packages/workflow/src/protocol/messages/StatusMessage.ts b/packages/workflow/src/protocol/messages/StatusMessage.ts index 97f4014ebe..948fbd1c8a 100644 --- a/packages/workflow/src/protocol/messages/StatusMessage.ts +++ b/packages/workflow/src/protocol/messages/StatusMessage.ts @@ -13,7 +13,7 @@ export class StatusMessage extends DidCommMessage { allowed_events: string[] action_menu: Array<{ label?: string; event: string }> artifacts: Record - ui?: any[] + ui?: import('../../model/types').UiItem[] } public constructor(options?: { id?: string; body: StatusMessage['body']; thid?: string }) { diff --git a/packages/workflow/src/repository/WorkflowInstanceRecord.ts b/packages/workflow/src/repository/WorkflowInstanceRecord.ts index ad729c9797..e5be2ca235 100644 --- a/packages/workflow/src/repository/WorkflowInstanceRecord.ts +++ b/packages/workflow/src/repository/WorkflowInstanceRecord.ts @@ -14,12 +14,13 @@ export interface WorkflowInstanceRecordProps { participants: Participants state: string section?: string - context: Record - artifacts: Record + context: Record + artifacts: Record status: WorkflowInstanceStatus history: InstanceHistoryItem[] multiplicityKeyValue?: string idempotencyKeys?: string[] + idempotency?: Array<{ key: string; event: string; to: string; actionKey?: string }> tags?: TagsBase } @@ -43,8 +44,8 @@ export class WorkflowInstanceRecord public participants!: Participants public state!: string public section?: string - public context!: Record - public artifacts!: Record + public context!: Record + public artifacts!: Record public status!: WorkflowInstanceStatus public history!: InstanceHistoryItem[] public multiplicityKeyValue?: string @@ -72,7 +73,7 @@ export class WorkflowInstanceRecord this.history = props.history ?? [] this.multiplicityKeyValue = props.multiplicityKeyValue this.idempotencyKeys = props.idempotencyKeys ?? [] - this.idempotency = (props as any).idempotency ?? [] + this.idempotency = props.idempotency ?? [] this._tags = props.tags ?? {} } } diff --git a/packages/workflow/src/repository/WorkflowInstanceRepository.ts b/packages/workflow/src/repository/WorkflowInstanceRepository.ts index b662a57e7a..3d8dcc7cec 100644 --- a/packages/workflow/src/repository/WorkflowInstanceRepository.ts +++ b/packages/workflow/src/repository/WorkflowInstanceRepository.ts @@ -1,4 +1,12 @@ -import { EventEmitter, InjectionSymbols, Repository, StorageService, inject, injectable } from '@credo-ts/core' +import { + AgentContext, + EventEmitter, + InjectionSymbols, + Repository, + StorageService, + inject, + injectable, +} from '@credo-ts/core' import { WorkflowInstanceRecord } from './WorkflowInstanceRecord' @injectable() @@ -10,12 +18,12 @@ export class WorkflowInstanceRepository extends Repository { +const stableStringify = (obj: unknown): string => { const allKeys: string[] = [] - JSON.stringify(obj, (k, v) => (allKeys.push(k), v)) + JSON.stringify(obj, (k, v) => { + allKeys.push(k) + return v + }) allKeys.sort() return JSON.stringify(obj, allKeys) } @@ -52,7 +55,7 @@ export class WorkflowService { template: WorkflowTemplate ): Promise { // JSON schema validation + structural checks - validateTemplateJson(template as any) + validateTemplateJson(template) validateTemplateRefs(template) const hash = sha256(stableStringify(template)) const existing = await this.templateRepo.findByTemplateIdAndVersion( @@ -163,7 +166,7 @@ export class WorkflowService { instance_id: string event: string idempotency_key?: string - input?: any + input?: Record } ): Promise { let inst: WorkflowInstanceRecord @@ -189,7 +192,7 @@ export class WorkflowService { // idempotency if (opts.idempotency_key && inst.idempotencyKeys?.includes(opts.idempotency_key)) { - const prior = (inst as any).idempotency?.find?.((i: any) => i.key === opts.idempotency_key) + const prior = inst.idempotency?.find?.((i) => i.key === opts.idempotency_key) if (prior && prior.event !== opts.event) throw this.problem('idempotency_conflict', 'same key, different event') return inst } @@ -246,8 +249,8 @@ export class WorkflowService { inst.artifacts = { ...inst.artifacts, ...artifactsDelta } if (opts.idempotency_key) { inst.idempotencyKeys = [...(inst.idempotencyKeys || []), opts.idempotency_key] - ;(inst as any).idempotency = [ - ...((inst as any).idempotency || []), + inst.idempotency = [ + ...(inst.idempotency || []), { key: opts.idempotency_key, event: opts.event, to: t.to, actionKey: t.action }, ] } @@ -305,7 +308,7 @@ export class WorkflowService { allowed_events: string[] action_menu: Array<{ label?: string; event: string }> artifacts: Record - ui?: any[] + ui?: import('../model/types').UiItem[] }> { let inst: WorkflowInstanceRecord try { @@ -328,12 +331,13 @@ export class WorkflowService { .map((t) => t.on) const includeActions = opts.include_actions ?? true const includeUi = opts.include_ui ?? true + const uiItems = ensureArray(tpl.display_hints?.states?.[inst.state]) const menu = includeActions - ? ensureArray(tpl.display_hints?.states?.[inst.state]) + ? uiItems .filter((i) => i?.type === 'button' || i?.type === 'submit-button') - .map((i) => ({ label: i?.label, event: i?.event })) + .map((i) => ({ label: i?.label, event: i?.event as string })) : [] - const ui = includeUi ? ensureArray(tpl.display_hints?.states?.[inst.state]) : undefined + const ui = includeUi ? uiItems : undefined return { instance_id: inst.instanceId, state: inst.state, @@ -470,8 +474,8 @@ export class WorkflowService { } private problem(code: string, comment: string) { - const err = new Error(comment) - ;(err as any).code = code + const err = new Error(comment) as Error & { code: string } + err.code = code return err } @@ -510,8 +514,8 @@ export class WorkflowService { } // profile_ref resolution for (const a of t.actions) { - if ('profile_ref' in a && (a as any).profile_ref) { - const pr = (a as any).profile_ref as string + if ('profile_ref' in a && (a as { profile_ref: string }).profile_ref) { + const pr = (a as { profile_ref: string }).profile_ref if (pr.startsWith('cp.')) { const key = pr.slice(3) if (!t.catalog?.credential_profiles || !t.catalog.credential_profiles[key]) fail(`catalog.cp missing: ${key}`) @@ -533,7 +537,7 @@ export class WorkflowService { thid: inst.instanceId, body: { instance_id: inst.instanceId, reason: 'state_final' }, }) - const outbound = new DidCommOutboundMessageContext(msg as any, { agentContext, connection }) + const outbound = new DidCommOutboundMessageContext(msg, { agentContext, connection }) await messageSender.sendMessage(outbound) } catch (e) { this.agentConfig.logger.debug(`Workflow complete notify error: ${(e as Error).message}`) From dc6339a6fc6580d80ddf472fdaac3db35278ff75 Mon Sep 17 00:00:00 2001 From: Vinay Singh Date: Sat, 27 Sep 2025 11:06:29 -0400 Subject: [PATCH 20/20] fix(workflow): removed tests from build Signed-off-by: Vinay Singh --- packages/workflow/tsconfig.build.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/workflow/tsconfig.build.json b/packages/workflow/tsconfig.build.json index 2b75d0adab..a86d73fcf1 100644 --- a/packages/workflow/tsconfig.build.json +++ b/packages/workflow/tsconfig.build.json @@ -3,5 +3,6 @@ "compilerOptions": { "outDir": "./build" }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/tests/**", "**/*.spec.ts", "**/*.test.ts", "**/__tests__/**", "**/__mocks__/**"] }