Add process temporal (#9744)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-09-01 22:10:14 +05:00
committed by GitHub
parent 8700bd5715
commit 6ca5751e68
46 changed files with 2924 additions and 1837 deletions
+2 -1
View File
@@ -666,7 +666,8 @@
"SECRET": "secret",
"ACCOUNTS_URL": "http://localhost:3000",
"QUEUE_CONFIG": "localhost:19092",
"QUEUE_REGION": "cockroach"
"QUEUE_REGION": "cockroach",
"TEMPORAL_ADDRESS": "localhost:7233"
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20",
+2209 -1740
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -567,6 +567,8 @@ services:
- STORAGE=minio|minio?accessKey=minioadmin&secretKey=minioadmin
- WORKSPACE_STORAGE=${STORAGE_CONFIG}
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
- QUEUE_CONFIG=${QUEUE_CONFIG}
- TEMPORAL_ADDRESS=localhost:7233
restart: unless-stopped
backup-api:
image: hardcoreeng/backup-api
+16
View File
@@ -1073,6 +1073,22 @@ export function createModel (builder: Builder): void {
process.trigger.OnSubProcessesDone
)
builder.createDoc(
process.class.Trigger,
core.space.Model,
{
label: process.string.WaitUntil,
icon: process.icon.Time,
requiredParams: [],
init: false,
auto: true,
editor: process.component.TimeEditor,
presenter: process.component.TimePresenter,
checkFunction: process.triggerCheck.Time
},
process.trigger.OnTime
)
builder.createDoc(card.class.MasterTagEditorSection, core.space.Model, {
id: 'processes',
label: process.string.Processes,
+4
View File
@@ -63,6 +63,10 @@ export function createModel (builder: Builder): void {
serverCheckFunc: serverProcess.func.OnCardUpdateCheck
})
builder.mixin(process.trigger.OnTime, process.class.Trigger, serverProcess.mixin.TriggerImpl, {
serverCheckFunc: serverProcess.func.CheckTime
})
builder.mixin(process.trigger.OnSubProcessesDone, process.class.Trigger, serverProcess.mixin.TriggerImpl, {
preventRollback: true
})
-2
View File
@@ -66,8 +66,6 @@ export default mergeIds(viewId, view, {
BooleanPresenter: '' as AnyComponent,
BooleanEditor: '' as AnyComponent,
TimestampPresenter: '' as AnyComponent,
DateEditor: '' as AnyComponent,
DatePresenter: '' as AnyComponent,
DateTimePresenter: '' as AnyComponent,
TableBrowser: '' as AnyComponent,
YoutubePresenter: '' as AnyComponent,
+4
View File
@@ -32,4 +32,8 @@
<path fill-rule="evenodd" clip-rule="evenodd"
d="M26 8.00001C27.1046 8.00001 28 7.10457 28 6.00001C28 4.89544 27.1046 4.00001 26 4.00001C24.8955 4.00001 24 4.89544 24 6.00001C24 7.10457 24.8955 8.00001 26 8.00001ZM26 10C28.2092 10 30 8.20914 30 6.00001C30 3.79087 28.2092 2.00001 26 2.00001C23.7909 2.00001 22 3.79087 22 6.00001C22 8.20914 23.7909 10 26 10Z"/>
</symbol>
<symbol id="time" viewBox="0 0 14 14">
<path d="M7.43758 3.49984C7.43758 3.25821 7.24171 3.06234 7.00008 3.06234C6.75846 3.06234 6.56258 3.25821 6.56258 3.49984V6.99984C6.56258 7.11587 6.60868 7.22715 6.69072 7.3092L8.44072 9.0592C8.61158 9.23005 8.88859 9.23005 9.05944 9.0592C9.2303 8.88834 9.2303 8.61133 9.05944 8.44048L7.43758 6.81862V3.49984Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.00008 0.729004C3.5368 0.729004 0.729248 3.53655 0.729248 6.99984C0.729248 10.4631 3.5368 13.2707 7.00008 13.2707C10.4634 13.2707 13.2709 10.4631 13.2709 6.99984C13.2709 3.53655 10.4634 0.729004 7.00008 0.729004ZM1.60425 6.99984C1.60425 4.0198 4.02005 1.604 7.00008 1.604C9.98012 1.604 12.3959 4.0198 12.3959 6.99984C12.3959 9.97987 9.98012 12.3957 7.00008 12.3957C4.02005 12.3957 1.60425 9.97987 1.60425 6.99984Z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

+2 -1
View File
@@ -96,7 +96,8 @@
"Remove": "Odstranit",
"RemoveFirst": "Odstranit první",
"RemoveLast": "Odstranit poslední",
"Cancelled": "Zrušeno"
"Cancelled": "Zrušeno",
"WaitUntil": "Čekat do"
},
"error": {
"MethodNotFound": "Metoda nenalezena: {methodId}",
+3 -2
View File
@@ -96,8 +96,9 @@
"Remove": "Entfernen",
"RemoveFirst": "Erstes entfernen",
"RemoveLast": "Letztes entfernen",
"Cancelled": "Abgebrochen"
},
"Cancelled": "Abgebrochen",
"WaitUntil": "Warten bis"
},
"error": {
"MethodNotFound": "Methode nicht gefunden: {methodId}",
"AttributeNotExists": "Attribut existiert nicht: {key}",
+2 -1
View File
@@ -101,7 +101,8 @@
"Remove": "Remove",
"RemoveFirst": "Remove first",
"RemoveLast": "Remove last",
"Cancelled": "Cancelled"
"Cancelled": "Cancelled",
"WaitUntil": "Wait until"
},
"error": {
"MethodNotFound": "Method not found: {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "Tamaño mayor o igual que",
"ArraySizeLt": "Tamaño menor que",
"ArraySizeLte": "Tamaño menor o igual que",
"Cancelled": "Cancelado"
"Cancelled": "Cancelado",
"WaitUntil": "Esperar hasta"
},
"error": {
"MethodNotFound": "Método no encontrado: {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "Taille supérieure ou égale à",
"ArraySizeLt": "Taille inférieure à",
"ArraySizeLte": "Taille inférieure ou égale à",
"Cancelled": "Annulé"
"Cancelled": "Annulé",
"WaitUntil": "Attendre jusqu'à"
},
"error": {
"MethodNotFound": "Méthode introuvable : {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "Dimensione maggiore o uguale a",
"ArraySizeLt": "Dimensione minore di",
"ArraySizeLte": "Dimensione minore o uguale a",
"Cancelled": "Annullato"
"Cancelled": "Annullato",
"WaitUntil": "Aspetta fino a"
},
"error": {
"MethodNotFound": "Metodo non trovato: {methodId}",
+2 -1
View File
@@ -100,7 +100,8 @@
"ArraySizeGte": "サイズが以上",
"ArraySizeLt": "サイズがより小さい",
"ArraySizeLte": "サイズが以下",
"Cancelled": "キャンセル済み"
"Cancelled": "キャンセル済み",
"WaitUntil": "待機する"
},
"error": {
"MethodNotFound": "メソッドが見つかりません: {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "Tamanho maior ou igual a",
"ArraySizeLt": "Tamanho menor que",
"ArraySizeLte": "Tamanho menor ou igual a",
"Cancelled": "Cancelado"
"Cancelled": "Cancelado",
"WaitUntil": "Aguardar até"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "Количество больше или равно",
"ArraySizeLt": "Количество меньше",
"ArraySizeLte": "Количество меньше или равно",
"Cancelled": "Отменено"
"Cancelled": "Отменено",
"WaitUntil": "Ждать до"
},
"error": {
"MethodNotFound": "Метод не найден: {methodId}",
+2 -1
View File
@@ -101,7 +101,8 @@
"ArraySizeGte": "大小大于等于",
"ArraySizeLt": "大小小于",
"ArraySizeLte": "大小小于等于",
"Cancelled": "已取消"
"Cancelled": "已取消",
"WaitUntil": "等待直到"
},
"error": {
"MethodNotFound": "找不到方法:{methodId}",
+2 -1
View File
@@ -25,5 +25,6 @@ loadMetadata(process.icon, {
ToDoRemove: `${icons}#todo-remove`,
WaitSubprocesses: `${icons}#subprocesses`,
Start: `${icons}#start`,
OnCardUpdate: `${icons}#card-update`
OnCardUpdate: `${icons}#card-update`,
Time: `${icons}#time`
})
@@ -0,0 +1,104 @@
<!--
// 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
// 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.
-->
<script lang="ts">
import core, { AnyAttribute, generateId } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Process } from '@hcengineering/process'
import { AnySvelteComponent } from '@hcengineering/ui'
import view from '@hcengineering/view'
import plugin from '../../plugin'
import { getContext } from '../../utils'
import ProcessAttribute from '../ProcessAttribute.svelte'
import { createEventDispatcher } from 'svelte'
export let readonly: boolean
export let process: Process
export let params: Record<string, any>
const client = getClient()
const h = client.getHierarchy()
$: context = getContext(client, process, core.class.TypeDate, 'attribute')
const attribute: AnyAttribute = {
attributeOf: process.masterTag,
name: '',
type: {
label: core.string.Date,
_class: core.class.TypeDate
},
_id: generateId(),
space: core.space.Model,
modifiedOn: 0,
modifiedBy: core.account.System,
_class: core.class.Attribute,
label: plugin.string.WaitUntil
}
let editor: AnySvelteComponent | undefined
function getEditor (): void {
try {
const inlineEditor = h.as(h.getClass(core.class.TypeDate), view.mixin.AttributeEditor).inlineEditor
void getResource(inlineEditor)
.then((p) => {
editor = p
})
.catch((e) => {
console.error(e)
})
} catch (e) {
console.error(e)
}
}
getEditor()
const dispatch = createEventDispatcher()
</script>
<div class="grid">
<ProcessAttribute
{process}
masterTag={process.masterTag}
{context}
presenterClass={{
attrClass: core.class.TypeDate,
category: 'attribute'
}}
{attribute}
{editor}
bind:value={params.value}
on:change={(e) => {
params.value = e.detail
dispatch('change', params)
}}
/>
</div>
<style lang="scss">
.grid {
display: grid;
grid-template-columns: 1fr 3fr;
grid-auto-rows: minmax(2rem, max-content);
justify-content: start;
align-items: center;
row-gap: 0.5rem;
column-gap: 1rem;
margin-top: 0.5rem;
height: min-content;
}
</style>
@@ -0,0 +1,39 @@
<!--
// 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
// 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.
-->
<script lang="ts">
import core from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { parseContext, Process } from '@hcengineering/process'
import { Component } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getContext } from '../../utils'
import ContextValuePresenter from '../attributeEditors/ContextValuePresenter.svelte'
export let process: Process
export let params: Record<string, any>
const client = getClient()
const h = client.getHierarchy()
$: context = getContext(client, process, core.class.TypeDate, 'attribute')
$: contextValue = parseContext(params.value)
</script>
{#if contextValue && context}
<ContextValuePresenter {contextValue} {context} {process} />
{:else}
<Component is={view.component.DatePresenter} props={{ value: params.value, readonly: true }} />
{/if}
+25 -14
View File
@@ -41,33 +41,41 @@ import ToDoRemoveParamsEditor from './components/settings/ToDoRemoveParamsEditor
import TransitionEditor from './components/settings/TransitionEditor.svelte'
import TriggerPresenter from './components/settings/TriggerPresenter.svelte'
import UpdateCardEditor from './components/settings/UpdateCardEditor.svelte'
import ArrayElementEditor from './components/transformEditors/ArrayElementEditor.svelte'
import DateOffsetEditor from './components/transformEditors/DateOffsetEditor.svelte'
import NumberEditor from './components/transformEditors/NumberEditor.svelte'
import ArrayElementEditor from './components/transformEditors/ArrayElementEditor.svelte'
import ArrayCriteria from './components/criterias/ArrayCriteria.svelte'
import BooleanCriteria from './components/criterias/BooleanCriteria.svelte'
import DateCriteria from './components/criterias/DateCriteria.svelte'
import EnumCriteria from './components/criterias/EnumCriteria.svelte'
import NumberCriteria from './components/criterias/NumberCriteria.svelte'
import RefCriteria from './components/criterias/RefCriteria.svelte'
import StringCriteria from './components/criterias/StringCriteria.svelte'
import LogActionPresenter from './components/LogActionPresenter.svelte'
import NotifierExtension from './components/NotifierExtension.svelte'
import AddRelationPresenter from './components/presenters/AddRelationPresenter.svelte'
import CreateCardPresenter from './components/presenters/CreateCardPresenter.svelte'
import AddRelationEditor from './components/settings/AddRelationEditor.svelte'
import CreateCardEditor from './components/settings/CreateCardEditor.svelte'
import CardUpdateEditor from './components/settings/CardUpdateEditor.svelte'
import CardUpdatePresenter from './components/settings/CardUpdatePresenter.svelte'
import CreateCardEditor from './components/settings/CreateCardEditor.svelte'
import TimeEditor from './components/settings/TimeEditor.svelte'
import TimePresenter from './components/settings/TimePresenter.svelte'
import ToDoSettingPresenter from './components/settings/ToDoPresenter.svelte'
import TransitionRefPresenter from './components/settings/TransitionRefPresenter.svelte'
import AppendEditor from './components/transformEditors/AppendEditor.svelte'
import CutEditor from './components/transformEditors/CutEditor.svelte'
import ReplaceEditor from './components/transformEditors/ReplaceEditor.svelte'
import SplitEditor from './components/transformEditors/SplitEditor.svelte'
import DateCriteria from './components/criterias/DateCriteria.svelte'
import BooleanCriteria from './components/criterias/BooleanCriteria.svelte'
import NumberCriteria from './components/criterias/NumberCriteria.svelte'
import StringCriteria from './components/criterias/StringCriteria.svelte'
import RefCriteria from './components/criterias/RefCriteria.svelte'
import EnumCriteria from './components/criterias/EnumCriteria.svelte'
import ArrayCriteria from './components/criterias/ArrayCriteria.svelte'
import CardUpdatePresenter from './components/settings/CardUpdatePresenter.svelte'
import ToDoSettingPresenter from './components/settings/ToDoPresenter.svelte'
import { ProcessMiddleware } from './middleware'
import { continueExecution, showDoneQuery, todoTranstionCheck, updateCardTranstionCheck } from './utils'
import {
continueExecution,
showDoneQuery,
timeTransitionCheck,
todoTranstionCheck,
updateCardTranstionCheck
} from './utils'
export * from './query'
@@ -114,7 +122,9 @@ export default async (): Promise<Resources> => ({
AddRelationEditor,
AddRelationPresenter,
CardUpdatePresenter,
ToDoSettingPresenter
ToDoSettingPresenter,
TimeEditor,
TimePresenter
},
criteriaEditor: {
DateCriteria,
@@ -136,7 +146,8 @@ export default async (): Promise<Resources> => ({
},
triggerCheck: {
UpdateCheck: updateCardTranstionCheck,
ToDo: todoTranstionCheck
ToDo: todoTranstionCheck,
Time: timeTransitionCheck
},
function: {
ShowDoneQuery: showDoneQuery,
+4 -1
View File
@@ -63,7 +63,9 @@ export default mergeIds(processId, process, {
AddRelationPresenter: '' as AnyComponent,
CardUpdateEditor: '' as AnyComponent,
CardUpdatePresenter: '' as AnyComponent,
ToDoSettingPresenter: '' as AnyComponent
ToDoSettingPresenter: '' as AnyComponent,
TimeEditor: '' as AnyComponent,
TimePresenter: '' as AnyComponent
},
criteriaEditor: {
StringCriteria: '' as AnyComponent,
@@ -153,6 +155,7 @@ export default mergeIds(processId, process, {
EnterValue: '' as IntlString,
OnToDoDone: '' as IntlString,
OnSubProcessesDone: '' as IntlString,
WaitUntil: '' as IntlString,
OnCardUpdate: '' as IntlString,
Result: '' as IntlString,
RequestResult: '' as IntlString,
+5
View File
@@ -574,6 +574,11 @@ export function todoTranstionCheck (params: Record<string, any>, doc: Doc): bool
return doc._id === params._id
}
export function timeTransitionCheck (params: Record<string, any>): boolean {
if (params.value === undefined) return false
return params.value <= Date.now()
}
export function updateCardTranstionCheck (params: Record<string, any>, doc: Doc, hierarchy: Hierarchy): boolean {
const res = matchQuery([doc], params, doc._class, hierarchy, true)
return res.length > 0
+6 -3
View File
@@ -209,11 +209,13 @@ export default plugin(processId, {
OnToDoClose: '' as Ref<Trigger>,
OnToDoRemove: '' as Ref<Trigger>,
OnExecutionStart: '' as Ref<Trigger>,
OnExecutionContinue: '' as Ref<Trigger>
OnExecutionContinue: '' as Ref<Trigger>,
OnTime: '' as Ref<Trigger>
},
triggerCheck: {
ToDo: '' as Resource<CheckFunc>,
UpdateCheck: '' as Resource<CheckFunc>
UpdateCheck: '' as Resource<CheckFunc>,
Time: '' as Resource<CheckFunc>
},
string: {
Method: '' as IntlString,
@@ -250,7 +252,8 @@ export default plugin(processId, {
OnCardUpdate: '' as Asset,
WaitSubprocesses: '' as Asset,
ToDoRemove: '' as Asset,
Start: '' as Asset
Start: '' as Asset,
Time: '' as Asset
},
function: {
FirstValue: '' as Ref<ProcessFunction>,
+3 -1
View File
@@ -183,7 +183,9 @@ const view = plugin(viewId, {
PersonIdFilter: '' as AnyComponent,
RolePresenter: '' as AnyComponent,
ReadOnlyNotification: '' as AnyComponent,
ForbiddenNotification: '' as AnyComponent
ForbiddenNotification: '' as AnyComponent,
DatePresenter: '' as AnyComponent,
DateEditor: '' as AnyComponent
},
ids: {
IconWithEmoji: '' as Asset
+5
View File
@@ -2687,6 +2687,11 @@
"packageName": "@hcengineering/huly-mail-resources",
"projectFolder": "plugins/huly-mail-resources",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-worker",
"projectFolder": "services/worker",
"shouldPublish": false
}
]
}
@@ -43,6 +43,11 @@ export function OnCardUpdateCheck (
return res.length > 0
}
export function CheckTime (params: Record<string, any>): boolean {
if (params.value === undefined) return false
return params.value <= Date.now()
}
export async function AddRelation (
params: MethodParams<Relation>,
execution: Execution,
@@ -66,7 +66,8 @@ import {
CreateCard,
AddRelation,
CheckToDo,
OnCardUpdateCheck
OnCardUpdateCheck,
CheckTime
} from './functions'
import { ToDoCancellRollback, ToDoCloseRollback } from './rollback'
@@ -334,7 +335,8 @@ export default async () => ({
CreateCard,
AddRelation,
CheckToDo,
OnCardUpdateCheck
OnCardUpdateCheck,
CheckTime
},
transform: {
FirstValue,
+2 -1
View File
@@ -47,7 +47,8 @@ export default plugin(serverProcessId, {
AddRelation: '' as Resource<ExecuteFunc>,
WaitSubProcess: '' as Resource<ExecuteFunc>,
CheckToDo: '' as Resource<CheckFunc>,
OnCardUpdateCheck: '' as Resource<CheckFunc>
OnCardUpdateCheck: '' as Resource<CheckFunc>,
CheckTime: '' as Resource<CheckFunc>
},
transform: {
FirstValue: '' as Resource<TransformFunc>,
+1
View File
@@ -65,6 +65,7 @@
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/account-client": "^0.6.0",
"@temporalio/client": "1.12.3",
"dotenv": "~16.0.0"
}
}
+5 -1
View File
@@ -21,13 +21,17 @@ export interface Config {
QueueConfig: string
QueueRegion: string
AccountsUrl: string
TemporalAddress: string
TemporalNamespace: string
}
const config: Config = {
Secret: process.env.SECRET ?? 'secret',
QueueConfig: process.env.QUEUE_CONFIG ?? '',
QueueRegion: process.env.QUEUE_REGION ?? '',
AccountsUrl: process.env.ACCOUNTS_URL ?? ''
AccountsUrl: process.env.ACCOUNTS_URL ?? '',
TemporalAddress: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
TemporalNamespace: process.env.TEMPORAL_NAMESPACE ?? 'huly'
}
export default config
+7 -5
View File
@@ -19,13 +19,14 @@ import { newMetrics } from '@hcengineering/core'
import { getPlatformQueue } from '@hcengineering/kafka'
import { setMetadata } from '@hcengineering/platform'
import { initStatisticsContext, QueueTopic } from '@hcengineering/server-core'
import { join } from 'path'
import serverToken from '@hcengineering/server-token'
import config from './config'
import { ProcessMessage } from '@hcengineering/server-process'
import { SERVICE_NAME } from './utils'
import { messageHandler } from './main'
import serverToken from '@hcengineering/server-token'
import { join } from 'path'
import config from './config'
import { prepare } from './init'
import { messageHandler } from './main'
import { closeTemporal } from './temporal'
import { SERVICE_NAME } from './utils'
async function main (): Promise<void> {
prepare()
@@ -62,6 +63,7 @@ async function main (): Promise<void> {
)
const shutdown = (): void => {
void closeTemporal()
void Promise.all([consumer.close()]).then(() => {
process.exit()
})
+167 -15
View File
@@ -56,7 +56,10 @@ import serverProcess, {
TriggerImpl
} from '@hcengineering/server-process'
import { isError } from './errors'
import { getClient, pickTransition, releaseClient } from './utils'
import { getClient, releaseClient } from './utils'
import { getTemporalClient } from './temporal'
import { Client as TemporalClient } from '@temporalio/client'
import config from './config'
const activeExecutions = new Set<Ref<Execution>>()
@@ -64,7 +67,7 @@ export async function messageHandler (record: ProcessMessage, ws: WorkspaceUuid,
try {
const client = await getClient(ws, record.account)
try {
const control = {
const control: ProcessControl = {
ctx,
client,
cache: new Map<string, any>(),
@@ -114,14 +117,20 @@ async function processCardExecutions (control: ProcessControl, record: ProcessMe
states.add(transition.from)
}
}
if (states.size === 0) return
const executions = await control.client.findAll(process.class.Execution, {
card: record.card,
status: ExecutionStatus.Active,
currentState: { $in: Array.from(states) }
})
for (const execution of executions) {
await processExecution(control, record, execution)
if (states.size > 0) {
const executions = await control.client.findAll(process.class.Execution, {
card: record.card,
status: ExecutionStatus.Active,
currentState: { $in: Array.from(states) }
})
for (const execution of executions) {
await processExecution(control, record, execution)
}
}
// we should update timers, probably context value changed
if (record.event === process.trigger.OnCardUpdate) {
await updateTimers(control, record)
}
}
@@ -264,6 +273,7 @@ async function executeTransition (execution: Execution, transition: Transition,
status: isDone ? ExecutionStatus.Done : ExecutionStatus.Active,
error: null
})
executionUpdate.context = execution.context
res.push(client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, executionUpdate))
res.push(
client.txFactory.createTxCreateDoc(process.class.ExecutionLog, execution.space, {
@@ -286,12 +296,127 @@ async function executeTransition (execution: Execution, transition: Transition,
await checkParent(execution, control)
}
TxProcessor.applyUpdate(execution, executionUpdate)
await checkNext(control, execution)
const next = await checkNext(control, execution)
if (!next) {
await setNextTimers(control, execution)
}
} else {
await client.update(execution, { error: errors })
}
}
async function updateTimers (control: ProcessControl, record: ProcessMessage): Promise<void> {
const transitions = control.client.getModel().findAllSync(process.class.Transition, {
trigger: process.trigger.OnTime
})
const states = new Set<Ref<State>>()
for (const transition of transitions) {
const isContext = parseContext(transition.triggerParams?.value)
// we shouldn't update timers for non-context values
if (isContext !== undefined) {
if (transition.from !== null) {
states.add(transition.from)
}
}
}
if (states.size === 0) return
const executions = await control.client.findAll(process.class.Execution, {
card: record.card,
status: ExecutionStatus.Active,
currentState: { $in: Array.from(states) }
})
if (executions.length === 0) return
if (record.card !== undefined && !control.cache.has(record.card)) {
const card = await control.client.findOne(cardPlugin.class.Card, { _id: record.card })
if (card !== undefined) {
control.cache.set(record.card, card)
}
}
for (const execution of executions) {
try {
await updateExecutionTimers(control, execution)
} catch (err) {
console.error('Error updating execution timers:', err)
}
}
}
async function updateExecutionTimers (control: ProcessControl, execution: Execution): Promise<void> {
const transitions = control.client.getModel().findAllSync(process.class.Transition, {
from: execution.currentState,
process: execution.process,
trigger: process.trigger.OnTime
})
if (transitions.length === 0) return
const temporalClient = await getTemporalClient()
for (const transition of transitions) {
await setTimer(control, execution, transition, temporalClient)
}
}
async function setNextTimers (control: ProcessControl, execution: Execution): Promise<void> {
const temporalClient = await getTemporalClient()
await cleanTimers(execution, temporalClient)
const transitions = control.client.getModel().findAllSync(process.class.Transition, {
from: execution.currentState,
process: execution.process,
trigger: process.trigger.OnTime
})
for (const transition of transitions) {
await setTimer(control, execution, transition, temporalClient)
}
}
async function cleanTimers (execution: Execution, temporalClient: TemporalClient): Promise<void> {
try {
const res = await temporalClient.workflowService.listWorkflowExecutions({
namespace: config.TemporalNamespace,
query: `WorkflowType="processTimeWorkflow" AND ExecutionStatus="Running" AND ProcessExecution="${execution._id}"`
})
for (const ex of res.executions) {
try {
await temporalClient.workflowService.terminateWorkflowExecution({
workflowExecution: {
workflowId: ex.execution?.workflowId,
runId: ex.execution?.runId
},
reason: 'Outdated'
})
} catch (err) {
console.error('Error terminating workflow execution:', err)
}
}
} catch (err) {
console.error('Error cleaning timers:', err)
}
}
async function setTimer (
control: ProcessControl,
execution: Execution,
transition: Transition,
temporalClient: TemporalClient
): Promise<void> {
const filled = await fillParams(transition.triggerParams, execution, control)
const targetDate: number = filled.value
if (targetDate === undefined || typeof targetDate !== 'number' || targetDate === 0 || Number.isNaN(targetDate)) return
try {
await temporalClient.workflow.signalWithStart('processTimeWorkflow', {
taskQueue: 'process',
signal: 'setDate',
args: [targetDate, 'workspace', execution._id],
signalArgs: [targetDate],
workflowId: `${execution._id}_${transition._id}`,
searchAttributes: {
ProcessExecution: [execution._id]
}
})
} catch (e) {
console.error('Error setting timer:', e)
}
}
async function executeAction<T extends Doc> (
action: Step<T>,
transition: Ref<Transition>,
@@ -455,7 +580,7 @@ async function fillParams<T extends Doc> (
): Promise<MethodParams<T>> {
const res: MethodParams<T> = {}
for (const key in params) {
const value = (params as any)[key]
const value = control.client.getHierarchy().clone((params as any)[key])
const valueResult = await getContextValue(value, control, execution)
;(res as any)[key] = valueResult
}
@@ -486,7 +611,7 @@ async function getContextValue (value: any, control: ProcessControl, execution:
let value: any | undefined
try {
if (context.type === 'attribute') {
value = await getAttributeValue(control, execution, context)
value = getAttributeValue(control, execution, context)
} else if (context.type === 'relation') {
value = await getRelationValue(control, execution, context)
} else if (context.type === 'nested') {
@@ -505,6 +630,11 @@ async function getContextValue (value: any, control: ProcessControl, execution:
}
throw err
}
} else if (typeof value === 'object' && !Array.isArray(value)) {
for (const key in value) {
value[key] = await getContextValue(value[key], control, execution)
}
return value
} else {
return value
}
@@ -590,7 +720,7 @@ async function checkParent (execution: Execution, control: ProcessControl): Prom
await executeTransition(parent, transition, control)
}
async function checkNext (control: ProcessControl, execution: Execution): Promise<void> {
async function checkNext (control: ProcessControl, execution: Execution): Promise<boolean> {
const autoTriggers = control.client.getModel().findAllSync(process.class.Trigger, { auto: true })
const transitions = control.client.getModel().findAllSync(process.class.Transition, {
from: execution.currentState,
@@ -600,13 +730,35 @@ async function checkNext (control: ProcessControl, execution: Execution): Promis
if (transitions.length > 0) {
const doc = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
if (doc !== undefined) {
control.cache.set(execution.card, doc)
const transition = await pickTransition(control, execution, transitions, {
card: doc
})
if (transition !== undefined) {
control.cache.set(execution.card, doc)
await executeTransition(execution, transition, control)
return true
}
}
}
return false
}
export async function pickTransition (
control: ProcessControl,
execution: Execution,
transitions: Transition[],
context: Record<string, any>
): Promise<Transition | undefined> {
for (const tr of transitions) {
const trigger = control.client.getModel().findObject(tr.trigger)
if (trigger === undefined) continue
if (trigger.checkFunction === undefined) return tr
const impl = control.client.getHierarchy().as(trigger, serverProcess.mixin.TriggerImpl)
if (impl?.serverCheckFunc === undefined) return tr
const filled = await fillParams(tr.triggerParams, execution, control)
const checkFunc = await getResource(impl.serverCheckFunc)
if (checkFunc === undefined) continue
const res = await checkFunc(filled, context, control.client.getHierarchy())
if (res) return tr
}
}
+41
View File
@@ -0,0 +1,41 @@
//
// 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
// 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 { Client, Connection } from '@temporalio/client'
import config from './config'
let temporalConnection: Connection | Promise<Connection> | undefined
export async function closeTemporal (): Promise<void> {
if (temporalConnection !== undefined) {
if (temporalConnection instanceof Promise) {
temporalConnection = await temporalConnection
}
await temporalConnection.close()
}
}
export async function getTemporalClient (): Promise<Client> {
if (temporalConnection === undefined) {
temporalConnection = Connection.connect({
address: config.TemporalAddress
})
}
const temporalClient = new Client({
connection: await temporalConnection,
namespace: config.TemporalNamespace
})
return temporalClient
}
-39
View File
@@ -16,9 +16,6 @@
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { createRestTxOperations } from '@hcengineering/api-client'
import core, { PersonId, systemAccountUuid, TxOperations, WorkspaceUuid } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { Execution, parseContext, Transition } from '@hcengineering/process'
import serverProcess, { ProcessControl } from '@hcengineering/server-process'
import { generateToken } from '@hcengineering/server-token'
import config from './config'
@@ -101,39 +98,3 @@ async function createClient (workspaceUuid: WorkspaceUuid, socialId?: PersonId):
const client = await createRestTxOperations(transactorUrl, wsInfo.workspace, wsInfo.token, true)
return client
}
export async function pickTransition (
control: ProcessControl,
execution: Execution,
transitions: Transition[],
context: Record<string, any>
): Promise<Transition | undefined> {
for (const tr of transitions) {
const trigger = control.client.getModel().findObject(tr.trigger)
if (trigger === undefined) continue
if (trigger.checkFunction === undefined) return tr
const impl = control.client.getHierarchy().as(trigger, serverProcess.mixin.TriggerImpl)
if (impl?.serverCheckFunc === undefined) return tr
const filled = fillParams(tr.triggerParams, execution)
const checkFunc = await getResource(impl.serverCheckFunc)
if (checkFunc === undefined) continue
const res = await checkFunc(filled, context, control.client.getHierarchy())
if (res) return tr
}
}
function fillParams (params: Record<string, any>, execution: Execution): Record<string, any> {
const res: Record<string, any> = {}
for (const key in params) {
const value = params[key]
const context = parseContext(value)
if (context === undefined) {
res[key] = value
continue
}
if (context.type === 'context') {
res[key] = execution.context[context.id]
}
}
return res
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-bullseye-slim
RUN apt-get update \
&& apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
ENV NODE_ENV=production
RUN npm install --ignore-scripts=false --verbose @temporalio/worker @temporalio/workflow --unsafe-perm
COPY bundle/bundle.js ./
CMD [ "node", "bundle.js" ]
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@hcengineering/pod-worker",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Hardcore Engineering Inc.",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"bundle": "node ../../common/scripts/esbuild.js --keep-names=true --sourcemap=external --minify --external=@temporalio/*",
"_phase:bundle": "rushx bundle",
"_phase:docker-build": "rushx docker:build",
"_phase:docker-staging": "rushx docker:staging",
"docker:build": "../../common/scripts/docker_build.sh hardcoreeng/worker .",
"docker:tbuild": "docker build -t hardcoreeng/worker . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/worker",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/worker staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/worker",
"run-local": "cross-env QUEUE_CONFIG='localhost:19092' REGION='cockroach' ts-node src/index.ts",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@tsconfig/node16": "^1.0.4",
"cross-env": "~7.0.3",
"@types/node": "^22.15.29",
"@types/ws": "^8.5.11",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@hcengineering/platform-rig": "^0.6.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@types/uuid": "^8.3.1",
"@typescript-eslint/parser": "^6.11.0",
"esbuild": "^0.24.2",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"prettier": "^3.1.0",
"ts-node": "^10.8.0",
"typescript": "^5.8.3",
"@hcengineering/server-process": "^0.6.0"
},
"dependencies": {
"@temporalio/worker": "1.12.3",
"@temporalio/workflow": "1.12.3",
"@hcengineering/kafka": "^0.6.0",
"@hcengineering/process": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/server-core": "^0.6.1"
}
}
+26
View File
@@ -0,0 +1,26 @@
import core, { MeasureMetricsContext, type Ref, type WorkspaceUuid } from '@hcengineering/core'
import { getPlatformQueue } from '@hcengineering/kafka'
import process, { type Execution } from '@hcengineering/process'
import { QueueTopic } from '@hcengineering/server-core'
import type { ProcessMessage } from '@hcengineering/server-process'
export async function SendTimeEvent (ws: WorkspaceUuid, _execution: Ref<Execution>): Promise<void> {
const SERVICE_NAME = 'worker'
const queue = getPlatformQueue(SERVICE_NAME)
const ctx = new MeasureMetricsContext(SERVICE_NAME, {})
const producer = queue.getProducer<ProcessMessage>(ctx, QueueTopic.Process)
await producer.send(ctx, ws, [
{
account: core.account.System,
event: process.trigger.OnTime,
context: {},
execution: _execution
}
])
}
export default {
SendTimeEvent
}
+5
View File
@@ -0,0 +1,5 @@
import { runWorker } from './worker'
runWorker().catch((err) => {
console.error(err)
})
+36
View File
@@ -0,0 +1,36 @@
//
// 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
// 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 { NativeConnection, Worker } from '@temporalio/worker'
import * as activities from './activities'
export async function runWorker (): Promise<void> {
const connection = await NativeConnection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233'
})
try {
const worker = await Worker.create({
connection,
workflowsPath: require.resolve('./workflows'),
activities,
namespace: process.env.TEMPORAL_NAMESPACE ?? 'huly',
taskQueue: 'process'
})
await worker.run()
} finally {
await connection.close()
}
}
+62
View File
@@ -0,0 +1,62 @@
//
// 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
// 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 type { Ref, WorkspaceUuid } from '@hcengineering/core'
import type { Execution } from '@hcengineering/process'
import { defineSignal, proxyActivities, setHandler, sleep } from '@temporalio/workflow'
import activities from './activities'
const { SendTimeEvent } = proxyActivities<typeof activities>({
startToCloseTimeout: '10 minute'
})
const setDate = defineSignal<[number]>('setDate')
export async function processTimeWorkflow (
_targetDate: number,
ws: WorkspaceUuid,
_execution: Ref<Execution>
): Promise<void> {
let targetDate: number = _targetDate
let currentPromise: Promise<void> | undefined
while (true) {
const when = new Date(targetDate).getTime()
const delay = when - Date.now()
if (delay > 0) {
currentPromise = sleep(delay)
try {
await Promise.race([
currentPromise,
new Promise((resolve, reject) => {
setHandler(setDate, (newDate) => {
targetDate = newDate
reject(new Error('Date updated'))
})
})
])
} catch (error: any) {
if (error instanceof Error && error.message === 'Date updated') {
continue
}
throw error
}
}
await SendTimeEvent(ws, _execution)
break
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}