Process errors (#8476)

This commit is contained in:
Denis Bykhov
2025-04-06 20:20:47 +07:00
committed by GitHub
parent 9bf5234730
commit d2bdf88249
26 changed files with 517 additions and 101 deletions
@@ -0,0 +1,47 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { IntlString } from '@hcengineering/platform'
import { ExecutionError } from '@hcengineering/process'
import { ExecuteResult } from '@hcengineering/server-process'
export function isError (value: ExecuteResult | any): value is ExecutionError {
return (value as ExecutionError).error !== undefined
}
export class ProcessError extends Error {
constructor (
public readonly error: string,
public readonly message: IntlString,
public readonly props: Record<string, any>,
public readonly intlProps: Record<string, IntlString>,
public readonly shouldLog: boolean = false
) {
super(error)
}
}
export function processError (
message: IntlString,
props: Record<string, any> = {},
intlProps: Record<string, IntlString> = {},
shouldLog: boolean = false
): ProcessError {
return new ProcessError(message, message, props, intlProps, shouldLog)
}
export function parseError (err: ProcessError): ExecutionError {
return { error: err.message, props: err.props, intlProps: err.intlProps }
}
+171 -64
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@@ -29,9 +29,10 @@ import core, {
TxRemoveDoc,
TxUpdateDoc
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getEmbeddedLabel, getResource } from '@hcengineering/platform'
import process, {
Execution,
ExecutionError,
MethodParams,
parseContext,
Process,
@@ -45,6 +46,7 @@ import process, {
import { TriggerControl } from '@hcengineering/server-core'
import serverProcess, { ExecuteResult } from '@hcengineering/server-process'
import time, { ToDoPriority } from '@hcengineering/time'
import { isError, parseError, ProcessError, processError } from './errors'
export async function OnStateRemove (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
@@ -147,19 +149,27 @@ async function executeAction<T extends Doc> (
action: Step<T>,
execution: Execution,
control: TriggerControl
): Promise<ExecuteResult | undefined> {
): Promise<ExecuteResult> {
try {
const method = control.modelDb.findObject(action.methodId)
if (method === undefined) return undefined
if (method === undefined) throw processError(process.error.MethodNotFound, { methodId: action.methodId }, {}, true)
const impl = control.hierarchy.as(method, serverProcess.mixin.MethodImpl)
if (impl === undefined) return undefined
const f = await getResource(impl.func)
if (impl === undefined) throw processError(process.error.MethodNotFound, { methodId: action.methodId }, {}, true)
const params = await fillParams(action.params, execution, control)
const f = await getResource(impl.func)
const res = await f(params, execution, control)
return res
} catch (err) {
control.ctx.error(err instanceof Error ? err.message : String(err))
return undefined
if (err instanceof ProcessError) {
if (err.shouldLog) {
control.ctx.error(err.message, { props: err.props })
}
return parseError(err)
} else {
const errorId = generateId()
control.ctx.error(err instanceof Error ? err.message : String(err), { errorId })
return parseError(processError(process.error.InternalServerError, { errorId }))
}
}
}
@@ -169,21 +179,15 @@ async function fillValue (
control: TriggerControl,
execution: Execution
): Promise<any> {
if (value === undefined) {
// we should add error to execution
throw new Error('Value not found')
}
for (const func of context.functions ?? []) {
try {
const transform = control.modelDb.findObject(func.func)
if (transform === undefined) continue
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) continue
const funcImpl = control.hierarchy.as(transform, serverProcess.mixin.FuncImpl)
const f = await getResource(funcImpl.func)
value = await f(value, func.props, control, execution)
} catch (err: any) {
control.ctx.error(err)
const transform = control.modelDb.findObject(func.func)
if (transform === undefined) throw processError(process.error.MethodNotFound, { methodId: func.func }, {}, true)
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) {
throw processError(process.error.MethodNotFound, { methodId: func.func }, {}, true)
}
const funcImpl = control.hierarchy.as(transform, serverProcess.mixin.FuncImpl)
const f = await getResource(funcImpl.func)
value = await f(value, func.props, control, execution)
}
return value
}
@@ -195,32 +199,69 @@ async function getAttributeValue (
): Promise<any> {
const cardValue = await control.findAll(control.ctx, card.class.Card, { _id: execution.card }, { limit: 1 })
if (cardValue.length > 0) {
return getObjectValue(context.key, cardValue[0])
const val = getObjectValue(context.key, cardValue[0])
if (val == null) {
const attr = control.hierarchy.findAttribute(cardValue[0]._class, context.key)
throw processError(
process.error.EmptyAttributeContextValue,
{},
{ attr: attr?.label ?? getEmbeddedLabel(context.key) }
)
}
return val
} else {
throw processError(process.error.ObjectNotFound, { _id: execution.card }, {}, true)
}
}
async function getNestedValue (control: TriggerControl, execution: Execution, context: SelectedNested): Promise<any> {
async function getNestedValue (
control: TriggerControl,
execution: Execution,
context: SelectedNested
): Promise<any | ExecutionError> {
const cardValue = await control.findAll(control.ctx, card.class.Card, { _id: execution.card }, { limit: 1 })
if (cardValue.length === 0) return
if (cardValue.length === 0) throw processError(process.error.ObjectNotFound, { _id: execution.card }, {}, true)
const attr = control.hierarchy.findAttribute(cardValue[0]._class, context.path)
if (attr === undefined) return
if (attr === undefined) throw processError(process.error.AttributeNotExists, { key: context.path })
const nestedValue = getObjectValue(context.path, cardValue[0])
if (nestedValue === undefined) return
if (nestedValue === undefined) throw processError(process.error.EmptyAttributeContextValue, {}, { attr: attr.label })
const parentType = attr.type._class === core.class.ArrOf ? (attr.type as ArrOf<Doc>).of : attr.type
const targetClass = parentType._class === core.class.RefTo ? (parentType as RefTo<Doc>).to : parentType._class
const target = await control.findAll(control.ctx, targetClass, {
_id: { $in: Array.isArray(nestedValue) ? nestedValue : [nestedValue] }
})
if (target.length === 0) throw processError(process.error.RelatedObjectNotFound, {}, { attr: attr.label })
const nested = control.hierarchy.findAttribute(targetClass, context.key)
if (context.sourceFunction !== undefined) {
const transform = control.modelDb.findObject(context.sourceFunction)
if (transform === undefined) return
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) return
if (transform === undefined) {
throw processError(process.error.MethodNotFound, { methodId: context.sourceFunction }, {}, true)
}
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) {
throw processError(process.error.MethodNotFound, { methodId: context.sourceFunction }, {}, true)
}
const funcImpl = control.hierarchy.as(transform, serverProcess.mixin.FuncImpl)
const f = await getResource(funcImpl.func)
const reduced = await f(target, {}, control, execution)
return getObjectValue(context.key, reduced)
const val = getObjectValue(context.key, reduced)
if (val == null) {
throw processError(
process.error.EmptyRelatedObjectValue,
{},
{ parent: attr.label, attr: nested?.label ?? getEmbeddedLabel(context.key) }
)
}
return val
}
return getObjectValue(context.key, target[0])
const val = getObjectValue(context.key, target[0])
if (val == null) {
throw processError(
process.error.EmptyRelatedObjectValue,
{},
{ parent: attr.label, attr: nested?.label ?? getEmbeddedLabel(context.key) }
)
}
return val
}
async function getRelationValue (
@@ -229,25 +270,48 @@ async function getRelationValue (
context: SelectedRelation
): Promise<any> {
const assoc = control.modelDb.findObject(context.association)
if (assoc === undefined) return
if (assoc === undefined) throw processError(process.error.RelationNotExists, {})
const targetClass = context.direction === 'A' ? assoc.classA : assoc.classB
const q = context.direction === 'A' ? { docB: execution.card } : { docA: execution.card }
const relations = await control.findAll(control.ctx, core.class.Relation, { association: assoc._id, ...q })
if (relations.length === 0) return
const name = context.direction === 'A' ? assoc.nameA : assoc.nameB
if (relations.length === 0) throw processError(process.error.RelatedObjectNotFound, { attr: name })
const ids = relations.map((it) => {
return context.direction === 'A' ? it.docA : it.docB
})
const target = await control.findAll(control.ctx, targetClass, { _id: { $in: ids } })
const attr = control.hierarchy.findAttribute(targetClass, context.key)
if (target.length === 0) throw processError(process.error.RelatedObjectNotFound, { attr: name })
if (context.sourceFunction !== undefined) {
const transform = control.modelDb.findObject(context.sourceFunction)
if (transform === undefined) return
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) return
if (transform === undefined) {
throw processError(process.error.MethodNotFound, { methodId: context.sourceFunction }, {}, true)
}
if (!control.hierarchy.hasMixin(transform, serverProcess.mixin.FuncImpl)) {
throw processError(process.error.MethodNotFound, { methodId: context.sourceFunction }, {}, true)
}
const funcImpl = control.hierarchy.as(transform, serverProcess.mixin.FuncImpl)
const f = await getResource(funcImpl.func)
const reduced = await f(target, {}, control, execution)
return getObjectValue(context.key, reduced)
const val = getObjectValue(context.key, reduced)
if (val == null) {
throw processError(
process.error.EmptyRelatedObjectValue,
{ parent: name },
{ attr: attr?.label ?? getEmbeddedLabel(context.key) }
)
}
return val
}
return getObjectValue(context.key, target[0])
const val = getObjectValue(context.key, target[0])
if (val == null) {
throw processError(
process.error.EmptyRelatedObjectValue,
{ parent: name },
{ attr: attr?.label ?? getEmbeddedLabel(context.key) }
)
}
return val
}
async function fillParams<T extends Doc> (
@@ -267,15 +331,22 @@ async function fillParams<T extends Doc> (
async function getContextValue (value: any, control: TriggerControl, execution: Execution): Promise<any> {
const context = parseContext(value)
if (context !== undefined) {
let value = context.fallbackValue
if (context.type === 'attribute') {
value = await getAttributeValue(control, execution, context)
} else if (context.type === 'relation') {
value = await getRelationValue(control, execution, context)
} else if (context.type === 'nested') {
value = await getNestedValue(control, execution, context)
let value: any | undefined
try {
if (context.type === 'attribute') {
value = await getAttributeValue(control, execution, context)
} else if (context.type === 'relation') {
value = await getRelationValue(control, execution, context)
} else if (context.type === 'nested') {
value = await getNestedValue(control, execution, context)
}
return await fillValue(value, context, control, execution)
} catch (err: any) {
if (err instanceof ProcessError && context.fallbackValue !== undefined) {
return await fillValue(context.fallbackValue, context, control, execution)
}
throw err
}
return await fillValue(value === undefined ? context.fallbackValue : value, context, control, execution)
} else {
return value
}
@@ -287,40 +358,51 @@ async function changeState (
control: TriggerControl,
isDone: boolean = false
): Promise<Tx[]> {
const errors: ExecutionError[] = []
const res: Tx[] = []
const rollback: Tx[] = []
for (const action of state.actions) {
const actionResult = await executeAction(action, execution, control)
if (actionResult === undefined) continue
if (actionResult.rollback !== undefined) {
rollback.push(...actionResult.rollback)
}
res.push(...actionResult.txes)
}
if (state.endAction != null) {
const actionResult = await executeAction(state.endAction, execution, control)
if (actionResult !== undefined) {
if (isError(actionResult)) {
errors.push(actionResult)
} else {
if (actionResult.rollback !== undefined) {
rollback.push(...actionResult.rollback)
}
res.push(...actionResult.txes)
}
}
if (rollback.length > 0) {
execution.rollback[state._id] = rollback
if (state.endAction != null) {
const actionResult = await executeAction(state.endAction, execution, control)
if (isError(actionResult)) {
errors.push(actionResult)
} else {
if (actionResult.rollback !== undefined) {
rollback.push(...actionResult.rollback)
}
res.push(...actionResult.txes)
}
}
if (errors.length === 0) {
if (rollback.length > 0) {
execution.rollback[state._id] = rollback
res.push(
control.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
rollback: execution.rollback
})
)
}
res.push(
control.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
rollback: execution.rollback
currentState: state._id,
done: isDone
})
)
return res
} else {
return [control.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, { error: errors })]
}
res.push(
control.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
currentState: state._id,
done: isDone
})
)
return res
}
export async function OnExecutionCreate (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
@@ -553,6 +635,30 @@ export function FirstWorkingDayAfter (val: Timestamp): Timestamp {
return val
}
export async function OnExecutionContinue (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of txes) {
if (tx._class !== core.class.TxUpdateDoc) continue
const updateTx = tx as TxUpdateDoc<Execution>
if (!control.hierarchy.isDerived(updateTx.objectClass, process.class.Execution)) continue
if (updateTx.operations.error !== null) continue
const execution = (
await control.findAll(control.ctx, process.class.Execution, { _id: updateTx.objectId }, { limit: 1 })
)[0]
if (execution === undefined) continue
const _process = await control.modelDb.findOne(process.class.Process, { _id: execution.process })
if (_process === undefined) continue
const currentIndex = _process.states.findIndex((it) => it === execution.currentState)
const nextState = _process.states[currentIndex + 1]
if (nextState === undefined) continue
const states = await control.findAll(control.ctx, process.class.State, { _id: nextState })
if (states.length === 0) continue
const isDone = _process.states[currentIndex + 2] === undefined
res.push(...(await changeState(execution, states[0], control, isDone)))
}
return res
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
func: {
@@ -577,6 +683,7 @@ export default async () => ({
OnStateRemove,
OnProcessRemove,
OnProcessToDoClose,
OnProcessToDoRemove
OnProcessToDoRemove,
OnExecutionContinue
}
})