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/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/actions/ActionRegistry.ts b/packages/workflow/src/actions/ActionRegistry.ts new file mode 100644 index 0000000000..b5348e71a4 --- /dev/null +++ b/packages/workflow/src/actions/ActionRegistry.ts @@ -0,0 +1,207 @@ +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?: Record +} + +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 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 mergeValue === 'string' && ctx.input && mergeValue.includes('input.')) { + try { + const path = mergeValue + .replace(/\{\{|\}\}/g, '') + .trim() + .replace(/^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 as Record) + 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 = 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) + 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 = 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 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 = 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, + }) + let messageId: string | undefined = record?.id || record?.credentialRecord?.id + try { + 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) { + 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 = 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) + 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 = 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 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 = 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, 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', + proofFormats: { + anoncreds: { + name: 'Workflow Proof Request', + version: '1.0', + requested_attributes: reqAttrs, + requested_predicates: reqPreds, + }, + }, + willConfirm: true, + comment: profile.options?.comment, + }) + let messageId: string | undefined = record?.id || record?.proofRecord?.id + try { + 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) { + 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..703f53aa1c --- /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?: Record + }): 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?: import('../model/types').UiItem[] + }> { + 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/engine/AttributePlanner.ts b/packages/workflow/src/engine/AttributePlanner.ts new file mode 100644 index 0000000000..4bf1d2abfb --- /dev/null +++ b/packages/workflow/src/engine/AttributePlanner.ts @@ -0,0 +1,75 @@ +import jmespath from 'jmespath' +import { AttributeSpec, WorkflowInstanceData } from '../model/types' + +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 = {} + for (const [key, spec] of Object.entries(plan || {})) { + 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.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: 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): unknown { + // 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 jpSearch = jmespath.search as unknown as (obj: unknown, expr: string) => unknown + return jpSearch(env, expr) + } catch { + return undefined + } + } +} + +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] as Record, 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..bd2b6c4d4a --- /dev/null +++ b/packages/workflow/src/engine/GuardEvaluator.ts @@ -0,0 +1,39 @@ +import jmespath from 'jmespath' +import { Participants, WorkflowInstanceData } from '../model/types' + +export type GuardEnv = { + context: Record + participants: Participants + 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 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): unknown { + try { + const jpSearch = jmespath.search as unknown as (obj: unknown, expr: string) => unknown + return jpSearch(env, expression) + } catch { + return undefined + } + } + + public static envFromInstance(instance: WorkflowInstanceData): GuardEnv { + return { + context: instance.context || {}, + participants: instance.participants || {}, + artifacts: instance.artifacts || {}, + } + } +} 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' diff --git a/packages/workflow/src/model/TemplateValidation.ts b/packages/workflow/src/model/TemplateValidation.ts new file mode 100644 index 0000000000..a1dd0217de --- /dev/null +++ b/packages/workflow/src/model/TemplateValidation.ts @@ -0,0 +1,227 @@ +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 = { + 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 as unknown as object) + +export function validateTemplateJson(tpl: unknown) { + const ok = validate(tpl) + if (!ok) { + 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 + } +} + +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') 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}`) 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}`) as Error & { code?: string } + err.code = 'invalid_template' + throw err + } + if (!stateNames.has(tr.to)) { + 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}`) as Error & { code?: string } + err.code = 'invalid_template' + throw err + } + } + for (const a of t.actions) { + 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 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 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 + } + } + } +} diff --git a/packages/workflow/src/model/types.ts b/packages/workflow/src/model/types.ts new file mode 100644 index 0000000000..53fcf9d3aa --- /dev/null +++ b/packages/workflow/src/model/types.ts @@ -0,0 +1,110 @@ +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?: unknown } + +export type UiItem = { + type?: string + label?: string + event?: string + [key: string]: unknown +} + +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 : []) 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/handlers/AdvanceHandler.ts b/packages/workflow/src/protocol/handlers/AdvanceHandler.ts new file mode 100644 index 0000000000..3b60c262e1 --- /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 { code?: string }).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..d74f3b8a13 --- /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 { 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 { code?: string }).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..689926dbde --- /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 { 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 { code?: string }).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..531012e8b1 --- /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 { 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 { code?: string }).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..56a3afa0d8 --- /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 { code?: string }).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..40ae87b3e1 --- /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 { 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 { code?: string }).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..5e4afb15cb --- /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 { code?: string }).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..66b42689fa --- /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 { code?: string }).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/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..571bc826de --- /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?: Record } + + 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..948fbd1c8a --- /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?: import('../../model/types').UiItem[] + } + + 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 }) + } + } +} diff --git a/packages/workflow/src/repository/WorkflowInstanceRecord.ts b/packages/workflow/src/repository/WorkflowInstanceRecord.ts new file mode 100644 index 0000000000..e5be2ca235 --- /dev/null +++ b/packages/workflow/src/repository/WorkflowInstanceRecord.ts @@ -0,0 +1,92 @@ +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[] + idempotency?: Array<{ key: string; event: string; to: string; actionKey?: 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.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..3d8dcc7cec --- /dev/null +++ b/packages/workflow/src/repository/WorkflowInstanceRepository.ts @@ -0,0 +1,55 @@ +import { + AgentContext, + 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: AgentContext, templateId: string, connectionId?: string) { + return this.findByQuery(agentContext, { templateId, ...(connectionId ? { connectionId } : {}) }) + } + + public async findByTemplateConnAndMultiplicity( + agentContext: AgentContext, + templateId: string, + connectionId: string | undefined, + multiplicityKeyValue: string + ) { + return this.findByQuery(agentContext, { + templateId, + ...(connectionId ? { connectionId } : {}), + multiplicityKeyValue, + }) + } + + public async findByConnection(agentContext: AgentContext, connectionId: string) { + return this.findByQuery(agentContext, { connectionId }) + } + + public async findLatestByConnection(agentContext: AgentContext, 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: AgentContext, 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..28322f4c1c --- /dev/null +++ b/packages/workflow/src/repository/WorkflowTemplateRepository.ts @@ -0,0 +1,28 @@ +import { + AgentContext, + 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: AgentContext, 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] + } +} diff --git a/packages/workflow/src/services/WorkflowService.ts b/packages/workflow/src/services/WorkflowService.ts new file mode 100644 index 0000000000..bb193301e0 --- /dev/null +++ b/packages/workflow/src/services/WorkflowService.ts @@ -0,0 +1,546 @@ +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: unknown): string => { + const allKeys: string[] = [] + JSON.stringify(obj, (k, v) => { + allKeys.push(k) + return 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) + 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?: Record + } + ): 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.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 + } + + 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)) + 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.idempotency = [ + ...(inst.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?: import('../model/types').UiItem[] + }> { + 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)) + .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 + ? uiItems + .filter((i) => i?.type === 'button' || i?.type === 'submit-button') + .map((i) => ({ label: i?.label, event: i?.event as string })) + : [] + const ui = includeUi ? uiItems : 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) + 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) as Error & { code: string } + err.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 { 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}`) + } 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, { agentContext, connection }) + await messageSender.sendMessage(outbound) + } catch (e) { + this.agentConfig.logger.debug(`Workflow complete notify error: ${(e as Error).message}`) + } + } +} 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..3953cf68ba --- /dev/null +++ b/packages/workflow/src/tests/ActionHandlers.message-id-resolution.spec.ts @@ -0,0 +1,144 @@ +import type { AgentContext } from '@credo-ts/core' +import { IssueCredentialV2Action, PresentProofV2Action } from '..' +import type { ActionCtx } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' + +const makeAgentContext = (mocks: { + credentials?: { offerCredential: jest.Mock; findOfferMessage: jest.Mock } + proofs?: { requestProof: jest.Mock; findRequestMessage: jest.Mock } + connections?: { getById: jest.Mock } +}) => ({ + dependencyManager: { + 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 + return {} + }, + }, +}) + +const baseInstance: WorkflowInstanceData = { + 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 = { + 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: {}, + }, + }, + }, + } as unknown as WorkflowTemplate + const actionDef = { + 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: ActionCtx = { + agentContext: makeAgentContext({ credentials: credsMock1, connections }) as unknown as AgentContext, + template, + instance: baseInstance, + action: actionDef, + } + 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 + const credsMock2 = { + offerCredential: jest.fn(async () => ({ id: 'rec-2' })), + findOfferMessage: jest.fn(async (_id: string) => { + throw new Error('not found') + }), + } + const ctx2: ActionCtx = { + agentContext: makeAgentContext({ credentials: credsMock2, connections }) as unknown as AgentContext, + template, + instance: baseInstance, + action: actionDef, + } + 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 = { + 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: {}, + }, + }, + }, + } as unknown as WorkflowTemplate + const actionDef = { + 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: ActionCtx = { + agentContext: makeAgentContext({ proofs: proofsMock1, connections }) as unknown as AgentContext, + template, + instance: baseInstance, + action: actionDef, + } + const res1 = await action.execute(ctx1) + 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: ActionCtx = { + agentContext: makeAgentContext({ proofs: proofsMock2, connections }) as unknown as AgentContext, + template, + instance: baseInstance, + action: actionDef, + } + const res2 = await action.execute(ctx2) + 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..fb5409bf09 --- /dev/null +++ b/packages/workflow/src/tests/Engine.utilities.spec.ts @@ -0,0 +1,45 @@ +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: Record = { + a: { source: 'context', path: 'user.name', required: true }, + b: { source: 'static', value: 42 }, + c: { source: 'compute', expr: "join('', ['hi-','there'])" }, + } + 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: 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: 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: 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: Record = { + x: { source: 'compute', expr: 'invalid++expr' }, + y: { source: 'compute', expr: 'invalid++expr', required: true }, + } + const inst = { context: {} } as unknown as WorkflowInstanceData + // 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..d9de401629 --- /dev/null +++ b/packages/workflow/src/tests/IssueCredentialV2Action.spec.ts @@ -0,0 +1,54 @@ +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 = { + 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: {} } as unknown as WorkflowInstanceData, + } + // missing profile + await expect( + 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 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 = { + 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: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => credsApi } } as unknown as AgentContext, + template: tpl2, + 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 new file mode 100644 index 0000000000..59206931a9 --- /dev/null +++ b/packages/workflow/src/tests/IssueCredentialV2Action.to-ref-enforcement.spec.ts @@ -0,0 +1,56 @@ +import 'reflect-metadata' +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: 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 = { + connection_id: 'c1', + participants: { holder: { did: 'did:example:holder' } }, + context: {}, + state: 's', + artifacts: {}, + history: [], + } + const actionDef: import('..').ActionDef = { + key: 'k', + typeURI: 'https://didcomm.org/issue-credential/2.0/offer-credential', + profile_ref: 'cp.test', + } + const ctx = { + agentContext: { + dependencyManager: { + 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')) + return { + offerCredential: async () => { + throw new Error('should-not-send') + }, + } + return {} + }, + }, + }, + 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 new file mode 100644 index 0000000000..099639c508 --- /dev/null +++ b/packages/workflow/src/tests/LocalStateSetAction.spec.ts @@ -0,0 +1,41 @@ +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: 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 } } } as unknown as WorkflowInstanceData, + } + 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: 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: 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 new file mode 100644 index 0000000000..5fa6857423 --- /dev/null +++ b/packages/workflow/src/tests/PresentProofV2Action.spec.ts @@ -0,0 +1,74 @@ +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 = { + 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', + }, + }, + }, + } as unknown as WorkflowTemplate + const okApi = { + requestProof: jest.fn(async () => ({ id: 'r1' })), + findRequestMessage: jest.fn(async () => ({ message: { id: 'm1' } })), + } + const ctxOk: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => okApi } } as unknown as AgentContext, + template, + 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') + + const badApi = { + requestProof: jest.fn(async () => { + throw new Error('nope') + }), + } + const ctxBad: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => badApi } } as unknown as AgentContext, + template, + 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 = { + 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: ActionCtx = { + agentContext: { dependencyManager: { resolve: () => proofsApi } } as unknown as AgentContext, + template, + 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') + 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..3839c49d63 --- /dev/null +++ b/packages/workflow/src/tests/ProblemReportHandler.spec.ts @@ -0,0 +1,13 @@ +import { ProblemReportHandler, ProblemReportMessage } from '..' + +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: (_: unknown) => ({ logger: { warn() {} } }) } }, + message: msg, + } 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 new file mode 100644 index 0000000000..5585b19c13 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.additional.spec.ts @@ -0,0 +1,97 @@ +import { + CancelHandler, + CompleteHandler, + PauseHandler, + PublishTemplateHandler, + ResumeHandler, + StatusHandler, + StatusRequestMessage, + WorkflowModuleConfig, +} from '..' + +const makeAgentContext = () => ({ + dependencyManager: { + resolve: (ctor: unknown) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + return { logger: { info() {}, warn() {}, debug() {} } } + }, + }, +}) + +const inbound = (message: unknown) => ({ agentContext: makeAgentContext(), connection: { id: 'c1' }, message }) + +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 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 () => { + const svc = { + complete: async () => { + throw Object.assign(new Error('nope'), { code: 'invalid_event' }) + }, + status: async () => ({}), + } + 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() + }) + + 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 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: unknown, + opts: { instance_id: string; include_actions?: boolean; include_ui?: boolean } + ) => ({ + instance_id: opts.instance_id, + state: 's', + allowed_events: [], + action_menu: [], + artifacts: {}, + ui: [{}], + }), + } + 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) 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 new file mode 100644 index 0000000000..e3528734a2 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-controls.spec.ts @@ -0,0 +1,41 @@ +import { CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '..' + +const makeCtx = () => ({ + dependencyManager: { + resolve: (ctor: unknown) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + return { logger: { info() {}, warn() {}, debug() {} } } + }, + }, +}) + +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 () => { + 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 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' }) 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 new file mode 100644 index 0000000000..70d22f2802 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.problem-report-mapping.spec.ts @@ -0,0 +1,67 @@ +import { + AdvanceHandler, + AdvanceMessage, + StartHandler, + StartMessage, + StatusHandler, + StatusRequestMessage, + WorkflowModuleConfig, +} from '..' + +const makeAgentContext = () => ({ + dependencyManager: { + resolve: (ctor: unknown) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + return { logger: { info() {}, warn() {}, debug() {} } } + }, + }, +}) + +const makeInbound = (message: unknown) => ({ + agentContext: makeAgentContext(), + connection: { id: 'conn1' }, + message, +}) + +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 unknown as import('..').WorkflowService) + const message = new StartMessage({ body: { template_id: 'x' } }) + 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 () => { + const svc = { + advance: async () => { + throw Object.assign(new Error('bad'), { code: 'invalid_event' }) + }, + status: async () => ({}), + } + 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) 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 () => { + const svc = { + status: async () => { + throw Object.assign(new Error('nope'), { code: 'forbidden' }) + }, + } + 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) 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 new file mode 100644 index 0000000000..b44e801931 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolHandlers.status-responses.spec.ts @@ -0,0 +1,71 @@ +import { AdvanceHandler, CancelHandler, CompleteHandler, PauseHandler, ResumeHandler, WorkflowModuleConfig } from '..' + +const ctx = () => ({ + dependencyManager: { + resolve: (ctor: unknown) => { + if (ctor === WorkflowModuleConfig) return new WorkflowModuleConfig({ enableProblemReport: true }) + return { logger: { info() {}, warn() {}, debug() {} } } + }, + }, +}) + +const inbound = (message: unknown) => ({ agentContext: ctx(), connection: { id: 'c1' }, message }) + +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 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 unknown as { message?: { type?: string } })?.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 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 () => { + 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 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 new file mode 100644 index 0000000000..0a89825ce4 --- /dev/null +++ b/packages/workflow/src/tests/ProtocolMessages.constructors.spec.ts @@ -0,0 +1,65 @@ +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', () => { + 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: WorkflowTemplate = { + template_id: 't', + version: '1.0.0', + title: 'T', + 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.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..709b15113b --- /dev/null +++ b/packages/workflow/src/tests/PublishTemplateHandler.success.spec.ts @@ -0,0 +1,29 @@ +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 unknown as import('..').WorkflowService) + const msg = { + 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: unknown) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + }, + message: msg, + } 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 new file mode 100644 index 0000000000..f6753f35a0 --- /dev/null +++ b/packages/workflow/src/tests/StartHandler.success.spec.ts @@ -0,0 +1,28 @@ +import { StartHandler } from '..' + +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 unknown as import('..').WorkflowService) + const res = await handler.handle({ + agentContext: { + dependencyManager: { resolve: (_ctor: unknown) => ({ logger: { info() {}, warn() {}, debug() {} } }) }, + }, + connection: { id: 'c1' }, + message: { body: { template_id: 't' }, id: 'id1' }, + } 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 new file mode 100644 index 0000000000..4258d2edea --- /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, AgentConfig, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { agentDependencies } from '@credo-ts/node' +import { askar } from '@openwallet-foundation/askar-nodejs' +import { WorkflowModule } from '..' + +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' }, + } as unknown as AgentConfig, + 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: import('..').WorkflowTemplate = { + 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) + 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..e261c46137 --- /dev/null +++ b/packages/workflow/src/tests/TemplateRefs.valid.spec.ts @@ -0,0 +1,27 @@ +import { validateTemplateRefs } from '..' + +describe('validateTemplateRefs positive (cp.* and pp.*)', () => { + test('cp.* and pp.* profile_ref resolve to catalog entries', () => { + const tpl: import('..').WorkflowTemplate = { + 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..93308c50c8 --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.additional.spec.ts @@ -0,0 +1,60 @@ +import { validateTemplateJson } from '..' + +describe('Schemas extra invalid cases', () => { + test('invalid instance_policy (missing mode)', () => { + const bad = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: {}, + states: [], + transitions: [], + catalog: {}, + actions: [], + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('transitions invalid item shape', () => { + const bad = { + 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 = { + 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 = { + 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..44a72a7ccc --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.branches.spec.ts @@ -0,0 +1,57 @@ +import { validateTemplateJson } from '..' + +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 = { ...base, foo: 'bar' } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('instance_policy additionalProperties=false', () => { + const bad = { ...base, instance_policy: { mode: 'multi_per_connection', extra: 1 } } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('transitions item additionalProperties=false', () => { + const bad = { ...base, transitions: [{ from: 'a', to: 'a', on: 'x', extra: true }] } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('credential_profiles entry additionalProperties=false', () => { + const bad = { + ...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 = { + ...base, + catalog: { proof_profiles: { x: { to_ref: 'holder', extra: 'no' } } }, + } + expect(() => validateTemplateJson(bad)).toThrow() + }) + + test('requested_predicates item additionalProperties=false', () => { + const bad = { + ...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..6c8b1d1ea3 --- /dev/null +++ b/packages/workflow/src/tests/TemplateSchema.invalid.spec.ts @@ -0,0 +1,63 @@ +import { validateTemplateJson } from '..' + +describe('schemas.ts more invalids', () => { + test('transitions missing from', () => { + const bad: unknown = { + 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: unknown = { + 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: unknown = { + 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: unknown = { + 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..fb97b67239 --- /dev/null +++ b/packages/workflow/src/tests/TemplateValidation.additional-coverage.spec.ts @@ -0,0 +1,86 @@ +import { validateTemplateJson, validateTemplateRefs } from '..' + +describe('schemas.ts additional coverage', () => { + test('transitions missing on', () => { + const bad: unknown = { + 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: unknown = { + 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: Record = { + 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 = { + 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 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 new file mode 100644 index 0000000000..774b8c5092 --- /dev/null +++ b/packages/workflow/src/tests/TemplateValidation.invalid.spec.ts @@ -0,0 +1,50 @@ +import { validateTemplateJson, validateTemplateRefs } from '..' + +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)).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 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 unknown as import('..').WorkflowTemplate)).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 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 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 unknown as import('..').WorkflowTemplate)).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..d577988bad --- /dev/null +++ b/packages/workflow/src/tests/WorkflowApi.spec.ts @@ -0,0 +1,75 @@ +import { WorkflowApi, WorkflowService } from '..' + +describe('WorkflowApi pass-through', () => { + const make = () => { + const service = { + 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: 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 = {} 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: import('..').WorkflowTemplate = { + 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..b987ff9407 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.queries.additional.spec.ts @@ -0,0 +1,44 @@ +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 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' }) + ) + expect(out[0].id).toBe('x') + }) + + test('getByInstanceId calls findSingleByQuery', async () => { + 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 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 new file mode 100644 index 0000000000..48d6193ce7 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowInstanceRepository.spec.ts @@ -0,0 +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 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 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 new file mode 100644 index 0000000000..3d2b48f90d --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.extended.spec.ts @@ -0,0 +1,41 @@ +import { + DidCommCredentialEventTypes, + DidCommCredentialState, + 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 void> = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + 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: (...args: unknown[]) => void) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) + 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..b580374aac --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.inbound-events.spec.ts @@ -0,0 +1,114 @@ +import { + DidCommCredentialEventTypes, + DidCommCredentialState, + 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: unknown[] = [] + const listeners: Record void> = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + 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: Constructor) => handlers.push(h) } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: (...args: unknown[]) => void) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) + 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: unknown[] = [] + const listeners: Record void> = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + 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: Constructor) => handlers.push(h) } + if (name.includes('EventEmitter')) + return { + on: (evt: string, cb: (...args: unknown[]) => void) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) + // 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 void> = {} + const service = { autoAdvanceByConnection: jest.fn(async () => {}) } + const dm = { + 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: (...args: unknown[]) => void) => { + listeners[evt] = cb + }, + } + if (name.includes('WorkflowService')) return service + return {} + }, + } + await module.initialize({ dependencyManager: dm } as unknown as AgentContext) + 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..2f46c9cd42 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowModule.integration.spec.ts @@ -0,0 +1,157 @@ +import { AskarModule } from '@credo-ts/askar' +import { Agent, AgentConfig, ConsoleLogger, LogLevel } from '@credo-ts/core' +import { agentDependencies } from '@credo-ts/node' +import { askar } from '@openwallet-foundation/askar-nodejs' +import { WorkflowModule, WorkflowTemplate } from '..' + +const makeAgent = async () => { + const agent = new Agent({ + config: { + 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({ + 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 unknown as WorkflowTemplate) + 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 unknown as WorkflowTemplate)).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 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) + 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 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' } }) + 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 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) + 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..0fa74357cc --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.complete-send.spec.ts @@ -0,0 +1,115 @@ +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: WorkflowTemplate = { + 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 unknown as WorkflowTemplateRepository + let inst: unknown = { + 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: unknown, rec: unknown) => { + inst = rec + }, + } 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 = { + dependencyManager: { + 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 unknown as WorkflowTemplateRepository + let inst: unknown = { + 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: unknown, rec: unknown) => { + inst = rec + }, + } 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 = { + getById: jest.fn(async () => { + throw new Error('nope') + }), + } + const messageSender = { sendMessage: jest.fn(async () => {}) } + const agentContext = { + dependencyManager: { + 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 + 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..c56f401ad7 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.concurrency-and-conflicts.spec.ts @@ -0,0 +1,62 @@ +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: WorkflowTemplate = { + 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 unknown as import('../repository/WorkflowTemplateRepository').WorkflowTemplateRepository + const inst: WorkflowInstanceData = { + id: 'i1', + instanceId: 'i1', + templateId: 't', + templateVersion: '1.0.0', + state: 'a', + section: undefined, + context: {}, + artifacts: {}, + 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 unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository + const svc = new WorkflowService( + tplRepo, + instRepo, + new WorkflowModuleConfig({ + guardEngine: 'jmespath', + autoReturnExistingOnSingleton: true, + actionTimeoutMs: 15000, + enableProblemReport: true, + }), + { logger: { debug() {}, info() {} } } as unknown as AgentConfig + ) + try { + 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 new file mode 100644 index 0000000000..d0554e69fc --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.edge-cases.spec.ts @@ -0,0 +1,167 @@ +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: { multiplicity_key?: string; engine?: 'jmespath' | 'js' } = {}) => { + const tpl: WorkflowTemplate = { + 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 unknown as WorkflowTemplateRecord), + update: jest.fn(), + save: jest.fn(), + } 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, + 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, + }) + 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, 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 unknown as AgentContext, { instance_id: 'i' }) + expect(inst.status).toBe('active') + + inst.status = 'active' + 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, 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, getById, getByInstanceId } = makeSvc() + const inst = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'active', + participants: {}, + context: {}, + artifacts: {}, + history: [], + } + ;(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, agentConfig, findLatestByConnection } = makeSvc() + ;(findLatestByConnection as jest.Mock).mockResolvedValue({ + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + status: 'completed', + }) + await svc.autoAdvanceByConnection({} as unknown as AgentContext, '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('..').GuardEvaluator + jest.spyOn(Guard, 'evalValue').mockImplementation(() => { + throw new Error('bang') + }) + let saved: unknown + ;(instanceRepo.save as jest.Mock).mockImplementation(async (_ctx: AgentContext, rec: WorkflowInstanceRecord) => { + saved = rec + }) + 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, 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 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, getById, getByInstanceId } = makeSvc() + const inst = { id: 'i', instanceId: '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 new file mode 100644 index 0000000000..b32091cc07 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.lifecycle.spec.ts @@ -0,0 +1,108 @@ +import type { AgentConfig, AgentContext } from '@credo-ts/core' +import { WorkflowModuleConfig, WorkflowService } from '..' +import type { WorkflowInstanceData, WorkflowTemplate } from '..' + +const makeSvc = (inst: WorkflowInstanceData) => { + const tpl: WorkflowTemplate = { + 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 unknown as { + findByTemplateIdAndVersion: (ctx: AgentContext, id: string, v?: string) => Promise<{ template: WorkflowTemplate }> + } + const instanceRepo = { + getById: async () => inst, + getByInstanceId: async () => inst, + update: async () => {}, + } 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, + }) + 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, + } as unknown as WorkflowInstanceData + const { svc } = makeSvc(inst) + 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', + } as unknown as WorkflowInstanceData + const { svc } = makeSvc(inst) + 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', + } as unknown as WorkflowInstanceData + const { svc: s1 } = makeSvc(inst1) + 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', + } as unknown as WorkflowInstanceData + const { svc: s2 } = makeSvc(inst2) + 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 new file mode 100644 index 0000000000..247d7ecf02 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.publish-and-start.spec.ts @@ -0,0 +1,83 @@ +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 () => { + 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 unknown as WorkflowTemplate, + }) + const templateRepo = { + findByTemplateIdAndVersion: jest.fn(async () => existing), + update: jest.fn(async () => {}), + save: jest.fn(async () => {}), + } 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', + instance_policy: { mode: 'multi_per_connection' }, + states: [{ name: 'a', type: 'start' }], + transitions: [], + catalog: {}, + actions: [], + } + 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: WorkflowTemplate = { + 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 unknown as WorkflowTemplateRecord), + } as unknown as WorkflowTemplateRepository + 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 unknown as import('../repository/WorkflowInstanceRepository').WorkflowInstanceRepository + const svc = new WorkflowService( + templateRepo, + instanceRepo, + 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 new file mode 100644 index 0000000000..1faaee5094 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.status-options.spec.ts @@ -0,0 +1,76 @@ +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: WorkflowTemplate = { + 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 unknown as WorkflowTemplateRecord), + } as unknown as WorkflowTemplateRepository + const inst = { + id: 'i', + instanceId: 'i', + templateId: 't', + templateVersion: '1', + state: 'a', + section: 'S', + context: {}, + artifacts: {}, + history: [], + status: 'active', + } 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 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 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.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..ae07d1e379 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowService.template-validation.spec.ts @@ -0,0 +1,89 @@ +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 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 unknown as WorkflowService + } + + test('valid template passes', () => { + const tpl = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' as const }, + sections: [{ name: 'Main' }], + states: [{ name: 's', type: 'start' as const, 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(() => validateTemplateJson(tpl)).not.toThrow() + expect(() => validateTemplateRefs(tpl as unknown as import('..').WorkflowTemplate)).not.toThrow() + }) + + test('invalid transitions and actions throw', () => { + const bad1 = { + template_id: 't', + version: '1', + title: 'T', + 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(() => validateTemplateRefs(bad1 as unknown as import('..').WorkflowTemplate)).toThrow() + const bad2 = { + template_id: 't', + version: '1', + title: 'T', + 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(() => validateTemplateRefs(bad2 as unknown as import('..').WorkflowTemplate)).toThrow() + const bad3 = { + template_id: 't', + version: '1', + title: 'T', + 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(() => validateTemplateRefs(bad3 as unknown as import('..').WorkflowTemplate)).toThrow() + }) + + test('validateTemplateJson rejects actions missing key', () => { + const bad = { + template_id: 't', + version: '1', + title: 'T', + instance_policy: { mode: 'multi_per_connection' as const }, + states: [{ name: 's', type: 'start' as const }], + 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..628cf79535 --- /dev/null +++ b/packages/workflow/src/tests/WorkflowTemplateRepository.spec.ts @@ -0,0 +1,26 @@ +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 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' } }, + ] + 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') + }) +}) 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) diff --git a/packages/workflow/tsconfig.build.json b/packages/workflow/tsconfig.build.json new file mode 100644 index 0000000000..a86d73fcf1 --- /dev/null +++ b/packages/workflow/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["src/**/*"], + "exclude": ["src/tests/**", "**/*.spec.ts", "**/*.test.ts", "**/__tests__/**", "**/__mocks__/**"] +} 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"] + } +} 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 diff --git a/tests/workflow-anoncreds.e2e.test.ts b/tests/workflow-anoncreds.e2e.test.ts new file mode 100644 index 0000000000..6cb9dd1edd --- /dev/null +++ b/tests/workflow-anoncreds.e2e.test.ts @@ -0,0 +1,261 @@ +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 { SubjectInboundTransport } from './transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from './transport/SubjectOutboundTransport' + +import { Agent } from '@credo-ts/core' +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 + 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, + 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: import('../packages/workflow/src').WorkflowTemplate = { + template_id: 'ui-flow', + version: '1.0.0', + title: 'Workflow UI and Issue', + instance_policy: { mode: 'singleton_per_connection' as const }, + sections: [{ name: 'Main' }], + states: [ + { 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: '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' }, + ], + }, + }, + } + 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 issuerWorkflow.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 issuerWorkflow.status({ instance_id: inst.instanceId, include_ui: true }) + expect(s1.state).toBe('menu') + // UI items - various types + 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' }, + ]) + ) + + // Submit fields via save event + 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 issuerWorkflow.status({ instance_id: inst.instanceId }) + expect(s2.allowed_events).toContain('request_confirm') + + // Issuer requests confirmation (moves to 'confirm') + 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, { + agentContext: holderAgent.context, + connection: holderConn, + }) + await sender.sendMessage(outbound) + + // Wait for holder to receive the offer explicitly, then accept + const holderOffer = await waitForHolderOffer(holderAgent) + 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 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 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') + }) +}) + +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.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') +} + +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) => (r.state as unknown as string) === 'offer-received') + if (rec) return rec + await new Promise((r) => setTimeout(r, intervalMs)) + } + throw new Error('Timeout waiting for holder offer') +}