Merge remote-tracking branch 'origin/develop' into staging-new

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2025-09-19 09:27:28 +07:00
25 changed files with 564 additions and 458 deletions
+1
View File
@@ -617,6 +617,7 @@
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"sourceMaps": true,
"nodeVersionHint": 22,
"cwd": "${workspaceRoot}/services/github/pod-github",
"protocol": "inspector",
"outputCapture": "std"
+50 -3
View File
@@ -1319,9 +1319,41 @@ async function migrateAccount (
accountDB: AccountDB,
dryRun = true
): Promise<AccountUuid | undefined> {
const primaryKey: SocialKey = {
type: SocialIdType.EMAIL,
value: account.email
let primaryKey: SocialKey
let secondaryKey: SocialKey | undefined
if (account.githubId != null) {
if (account.githubUser == null) {
console.log('No github user found for github id', account.githubId)
return
}
primaryKey = {
type: SocialIdType.GITHUB,
value: account.githubUser
}
secondaryKey = !account.email.startsWith('github:')
? {
type: SocialIdType.EMAIL,
value: account.email
}
: undefined
} else if (account.openId != null) {
primaryKey = {
type: SocialIdType.OIDC,
value: account.openId
}
secondaryKey = !account.email.startsWith('openid:')
? {
type: SocialIdType.EMAIL,
value: account.email
}
: undefined
} else {
primaryKey = {
type: SocialIdType.EMAIL,
value: account.email
}
}
let personUuid: PersonUuid
@@ -1389,6 +1421,21 @@ async function migrateAccount (
}
}
if (secondaryKey != null) {
const existingSecondary = await accountDB.socialId.findOne(secondaryKey)
if (existingSecondary == null) {
if (!dryRun) {
await accountDB.socialId.insertOne({
...secondaryKey,
personUuid,
...verified
})
} else {
console.log('Creating secondary social id', { personUuid, confirmed: account.confirmed, secondaryKey })
}
}
}
return personUuid as AccountUuid
}
+1
View File
@@ -33,6 +33,7 @@ export function createModel (builder: Builder): void {
icon: aiAssistant.component.IconHulyAssistant,
allowMultiple: false,
createComponent: aiAssistant.component.Connect,
configureComponent: aiAssistant.component.Connect,
onDisconnect: aiAssistant.handler.DisconnectHandler,
onDisconnectAll: aiAssistant.handler.DisconnectAllHandler,
reconnectComponent: aiAssistant.component.Connect,
@@ -116,9 +116,6 @@
{ type: communication.type.Direct, order: 2 }
],
fixedTypes: [chat.masterTag.Thread, communication.type.Direct],
specialSorting: {
[communication.type.Direct]: 'alphabetical'
},
allowCreate: true,
defaultSorting: 'recent',
lookback: '2w',
+32 -23
View File
@@ -72,7 +72,7 @@ class RequestPromise {
reject!: (reason?: any) => void
reconnect?: () => void
// Required to proeprly handle rate limits
// Required to properly handle rate limits
sendData: () => void = () => {}
constructor (
@@ -92,6 +92,11 @@ class RequestPromise {
const globalRPCHandler: RPCHandler = new RPCHandler()
interface OnConnectHandler {
resolve: () => void
reject: (err: Error) => void
}
class Connection implements ClientConnection {
private websocket: ClientSocket | null = null
binaryMode = false
@@ -99,7 +104,7 @@ class Connection implements ClientConnection {
private readonly requests = new Map<ReqId, RequestPromise>()
private lastId = 0
private interval: number | undefined
private dialTimer: any | undefined
private dialTimer: number | undefined
private sockets = 0
private openAction: any
@@ -166,12 +171,13 @@ class Connection implements ClientConnection {
}
private schedulePing (socketId: number): void {
clearInterval(this.interval)
this.pingResponse = Date.now()
const wsocket = this.websocket
const interval = setInterval(() => {
clearInterval(this.interval)
this.interval = setInterval(() => {
if (wsocket !== this.websocket) {
clearInterval(interval)
clearInterval(this.interval)
return
}
if (!this.upgrading && this.pingResponse !== 0 && Date.now() - this.pingResponse > hangTimeout) {
@@ -203,7 +209,6 @@ class Connection implements ClientConnection {
clearInterval(this.interval)
}
}, pingTimeout)
this.interval = interval
}
async close (): Promise<void> {
@@ -211,6 +216,12 @@ class Connection implements ClientConnection {
clearTimeout(this.openAction)
clearTimeout(this.dialTimer)
clearInterval(this.interval)
for (const handler of this.onConnectHandlers) {
handler.reject(new Error('Connection closed'))
}
for (const req of this.requests.values()) {
req.reject(new Error('Connection closed'))
}
if (this.websocket !== null) {
this.websocket.close(1000)
this.websocket = null
@@ -222,7 +233,7 @@ class Connection implements ClientConnection {
}
delay = 0
onConnectHandlers: (() => void)[] = []
onConnectHandlers: OnConnectHandler[] = []
private waitOpenConnection (ctx: MeasureContext): Promise<void> | undefined {
if (this.isConnected()) {
@@ -233,9 +244,10 @@ class Connection implements ClientConnection {
'wait-connection',
{},
(ctx) =>
new Promise((resolve) => {
this.onConnectHandlers.push(() => {
resolve()
new Promise((resolve, reject) => {
this.onConnectHandlers.push({
resolve,
reject
})
// Websocket is null for first time
this.scheduleOpen(ctx, false)
@@ -294,7 +306,7 @@ class Connection implements ClientConnection {
if (resp.error !== undefined) {
if (resp.error?.code === UNAUTHORIZED.code || resp.terminate === true) {
if (
resp.error.code !== platform.status.WorkspaceArchived ||
resp.error.code !== platform.status.WorkspaceArchived &&
resp.error.code !== platform.status.WorkspaceNotFound
) {
Analytics.handleError(new PlatformError(resp.error))
@@ -350,7 +362,7 @@ class Connection implements ClientConnection {
// We need to clear dial timer, since we recieve hello response.
clearTimeout(this.dialTimer)
this.dialTimer = null
this.dialTimer = undefined
this.lastHash = (resp as HelloResponse).lastHash
const serverVersion = helloResp.serverVersion
@@ -374,7 +386,7 @@ class Connection implements ClientConnection {
// Notify all waiting connection listeners
const handlers = this.onConnectHandlers.splice(0, this.onConnectHandlers.length)
for (const h of handlers) {
h()
h.resolve()
}
for (const [, v] of this.requests.entries()) {
@@ -540,12 +552,10 @@ class Connection implements ClientConnection {
return
}
this.websocket = wsocket
const opened = false
if (this.dialTimer != null) {
if (this.dialTimer === undefined) {
this.dialTimer = setTimeout(() => {
this.dialTimer = null
if (!opened && !this.closed) {
this.dialTimer = undefined
if (!this.closed) {
void this.opt?.onDialTimeout?.()?.catch((err) => {
this.ctx.error('failed to handle dial timeout', { err })
})
@@ -652,16 +662,15 @@ class Connection implements ClientConnection {
ctx.withSync('send-hello', {}, () => this.websocket?.send(this.rpcHandler.serialize(helloRequest, false)))
}
wsocket.onerror = (event: any) => {
// FIX: remove undefined variable 'opened'
wsocket.onerror = () => {
if (this.websocket !== wsocket) {
return
}
if (this.delay < 3) {
this.delay += 1
}
if (opened) {
console.error('client websocket error:', socketId, this.url, this.workspace, this.user)
}
console.error('client websocket error:', socketId, this.url, this.workspace, this.user)
}
}
@@ -761,7 +770,7 @@ class Connection implements ClientConnection {
getAccount (): Promise<Account> {
if (this.account !== undefined) {
return clone(this.account)
return Promise.resolve(clone(this.account))
}
return this.sendRequest({ method: 'getAccount', params: [] })
}
@@ -25,7 +25,8 @@
TableOfContents,
TableOfContentsContent,
getNodeElement,
highlightUpdateCommand
highlightUpdateCommand,
selectNode
} from '@hcengineering/text-editor-resources'
import { EditBox, Label, Scroller } from '@hcengineering/ui'
import { getCollaborationUser } from '@hcengineering/view-resources'
@@ -41,8 +42,8 @@
$documentCommentHighlightedLocation as documentCommentHighlightedLocation,
$documentComments as documentComments,
documentCommentsDisplayRequested,
documentCommentsHighlightUpdated,
documentCommentsLocationNavigateRequested,
documentCommentsAddCanceled,
$isEditable as isEditable
} from '../../stores/editors/document'
import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte'
@@ -58,19 +59,16 @@
let headings: Heading[] = []
let textEditor: CollaboratorEditor
let selectedNodeId: string | null | undefined = undefined
let isFocused = false
let editor: Editor
let title = $controlledDocument?.title ?? ''
$: isTemplate =
$controlledDocument != null && hierarchy.hasMixin($controlledDocument, documents.mixin.DocumentTemplate)
function handleRefreshHighlight () {
if (!textEditor) {
return
}
$: commentUuids = $documentComments.map((p) => p.nodeId).filter((id) => id != null)
textEditor.commands()?.command(highlightUpdateCommand())
function handleRefreshHighlight (): void {
textEditor?.commands()?.command(highlightUpdateCommand())
}
const unsubscribeHighlightRefresh = merge([documentCommentHighlightedLocation, documentComments.updates]).subscribe({
@@ -82,21 +80,28 @@
const unsubscribeNavigateToLocation = documentCommentsLocationNavigateRequested.subscribe({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
next: async ({ nodeId }) => {
if (!nodeId) {
if (nodeId == null) {
handleRefreshHighlight()
return
}
if (!textEditor) {
return
selectedNodeId = nodeId
if (editor !== undefined) {
await tick()
const element = getNodeElement(editor, nodeId)
element?.scrollIntoView({ behavior: 'smooth' })
}
}
})
await tick()
const element = getNodeElement(editor, nodeId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
const unsubscribeCommentsAddCanceled = documentCommentsAddCanceled.subscribe({
next: ({ nodeId }) => {
if (editor !== undefined && nodeId != null) {
if (selectNode(editor, nodeId)) {
editor.commands.unsetQMSInlineCommentMark()
}
}
}
})
@@ -104,6 +109,7 @@
onDestroy(() => {
unsubscribeHighlightRefresh()
unsubscribeNavigateToLocation()
unsubscribeCommentsAddCanceled()
})
const handleUpdateTitle = async () => {
@@ -137,15 +143,9 @@
return null
}
function handleShowDocumentComments (uuid: string) {
if (!uuid) {
return
}
documentCommentsDisplayRequested({
element: getNodeElement(editor, uuid),
nodeId: uuid
})
function handleShowDocumentComments (nodeId: string): void {
const element = getNodeElement(editor, nodeId)
documentCommentsDisplayRequested({ element, nodeId })
}
async function createEmbedding (file: File): Promise<{ file: Ref<Blob>, type: string } | undefined> {
@@ -241,32 +241,22 @@
qmsInlineComment: {
isHighlightModeOn: () => $canViewDocumentComments || $canAddDocumentComments,
getNodeHighlight: handleNodeHighlight,
onNodeSelected: (uuid) => {
if (selectedNodeId !== uuid) {
selectedNodeId = uuid
}
if (isFocused) {
documentCommentsHighlightUpdated(selectedNodeId !== null ? { nodeId: selectedNodeId } : null)
}
},
onNodeClicked: (uuid) => {
if (selectedNodeId !== uuid) {
selectedNodeId = uuid
}
onNodeClicked: (uuids) => {
// filter out those uuids that are not in comments
uuids = Array.isArray(uuids) ? uuids : [uuids]
uuids = uuids.filter((id) => commentUuids.includes(id)).sort()
if (!$arePopupsOpened && $canViewDocumentComments && selectedNodeId) {
// scroll through the comments as user clicks on the same node
const currIndex = selectedNodeId != null ? uuids.indexOf(selectedNodeId) : -1
const nextIndex = currIndex === -1 ? 0 : (currIndex + 1) % uuids.length
selectedNodeId = uuids[nextIndex]
if (!$arePopupsOpened && $canViewDocumentComments && selectedNodeId != null) {
handleShowDocumentComments(selectedNodeId)
}
}
}
},
hooks: {
focus: {
onFocus: (focused) => {
isFocused = focused
}
}
},
toc: {
onChange: (h) => {
headings = h
@@ -11,17 +11,29 @@
const dispatch = createEventDispatcher()
let messageId: Ref<ChatMessage> = generateId()
async function handleMessage (event: CustomEvent<string>): Promise<void> {
const messageId: Ref<ChatMessage> = generateId()
const comment = await addDocumentCommentFx({ content: event.detail, messageId, nodeId })
messageId = generateId()
dispatch('close', comment)
}
let popup: HTMLDivElement | undefined
function handleClick (event: MouseEvent): void {
if (event.target instanceof Node) {
if (popup !== undefined && !popup.contains(event.target)) {
event.preventDefault()
event.stopPropagation()
dispatch('close', undefined)
}
}
}
</script>
<div class="text-editor-popup w-85">
<svelte:window on:click|capture={handleClick} />
<div class="text-editor-popup w-85" bind:this={popup}>
<ReferenceInput
autofocus
focusable
@@ -96,6 +96,10 @@ export const savedAttachmentsUpdated = createEvent<Array<Ref<Attachment>>>(
generateActionName('savedAttachmentsUpdated')
)
export const documentCommentsAddCanceled = createEvent<{
nodeId?: string | null
}>(generateActionName('documentCommentsAddCanceled'))
export const documentCommentsDisplayRequested = createEvent<{
nodeId?: string | null
element?: PopupAlignment
@@ -19,9 +19,10 @@ import { type CompAndProps, type PopupAlignment, popupstore, showPopup } from '@
import documents, { type Document, type DocumentComment } from '@hcengineering/controlled-documents'
import { isDocumentCommentAttachedTo } from '../../../utils'
import {
DocumentCommentPopupCategory,
type DocumentCommentsFilter,
DocumentCommentPopupCategory,
documentCommentPopupsOpened,
documentCommentsAddCanceled,
documentCommentsDisplayRequested,
documentCommentsHighlightCleared,
documentCommentsHighlightUpdated,
@@ -31,7 +32,8 @@ import {
documentCommentsSortByChanged,
documentCommentsUpdated,
controlledDocumentClosed,
savedAttachmentsUpdated
savedAttachmentsUpdated,
controlledDocumentOpened
} from './actions'
export const $areDocumentCommentPopupsOpened = createStore(false).on(
@@ -120,6 +122,7 @@ export const showAddCommentPopupFx = createEffect((payload: { element?: PopupAli
payload.element,
(result) => {
if (result === null || result === undefined) {
documentCommentsAddCanceled({ nodeId: payload.nodeId })
documentCommentsHighlightCleared()
} else {
documentCommentsDisplayRequested(payload)
@@ -187,4 +190,5 @@ export const $savedAttachments = createStore<Array<Ref<Attachment>>>([])
.on(savedAttachmentsUpdated, (_, payload) => payload)
.reset(controlledDocumentClosed)
forward({ from: controlledDocumentOpened, to: documentCommentsHighlightCleared })
forward({ from: documentCommentsLocationNavigateRequested, to: documentCommentsHighlightUpdated })
@@ -24,7 +24,7 @@ import { getCurrentEmployee } from '@hcengineering/contact'
import { RequestStatus } from '@hcengineering/request'
import { getClient } from '@hcengineering/presentation'
import { type ActionContext } from '@hcengineering/text-editor'
import { getNodeElement, selectNode, nodeUuidName } from '@hcengineering/text-editor-resources'
import { getNodeElement, selectNode } from '@hcengineering/text-editor-resources'
import { showAddCommentPopupFx } from './stores/editors/document'
import { $editorMode } from './stores/editors/document/editor'
@@ -127,39 +127,20 @@ async function canAddDocumentComments (doc: ControlledDocument, mode: EditorMode
return false
}
function setQMSInlineCommentMark (editor: Editor): string | undefined {
if (editor === undefined) {
export async function comment (editor: Editor, event: MouseEvent, ctx: ActionContext): Promise<void> {
const { objectId, objectClass } = ctx
if (editor === undefined || objectId === undefined || objectClass === undefined) {
return
}
const nodeId = generateId()
editor.commands.setQMSInlineCommentMark(nodeId)
return nodeId
}
const element = getNodeElement(editor, nodeId)
await showAddCommentPopupFx({ element, nodeId })
export async function comment (editor: Editor, event: MouseEvent, ctx: ActionContext): Promise<void> {
const { objectId, objectClass } = ctx
if (objectId === undefined || objectClass === undefined) {
return
}
let selectedNodeId = editor.extensionStorage[nodeUuidName].activeNodeUuid
if (selectedNodeId == null) {
selectedNodeId = setQMSInlineCommentMark(editor)
}
if (selectedNodeId == null) {
return
}
await showAddCommentPopupFx({
element: getNodeElement(editor, selectedNodeId),
nodeId: selectedNodeId
})
selectNode(editor, selectedNodeId)
selectNode(editor, nodeId)
}
export async function isCommentVisible (editor: Editor, ctx: ActionContext): Promise<boolean> {
@@ -22,7 +22,7 @@
export let context: Context
const client = getClient()
const func = client.getModel().findObject(contextValue.func)
$: func = client.getModel().findObject(contextValue.func)
</script>
{#if func !== undefined}
@@ -17,11 +17,11 @@
import { translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Process, State } from '@hcengineering/process'
import { makeRank } from '@hcengineering/rank'
import { Button, IconAdd, Label } from '@hcengineering/ui'
import { SortableDocList } from '@hcengineering/view-resources'
import plugin from '../../plugin'
import StateInlineEditor from './StateInlineEditor.svelte'
import { makeRank } from '@hcengineering/rank'
import { SortableList } from '@hcengineering/view-resources'
export let process: Process
export let states: State[]
@@ -49,11 +49,11 @@
<div class="header w-full p-4">
<Label label={plugin.string.States} />
</div>
<SortableList _class={plugin.class.State} query={{ process: process._id }} isAddButtonHidden>
<SortableDocList _class={plugin.class.State} query={{ process: process._id }}>
<svelte:fragment slot="object" let:value>
<StateInlineEditor value={toState(value)} />
</svelte:fragment>
</SortableList>
</SortableDocList>
{#if !readonly}
<Button kind={'ghost'} width={'100%'} icon={IconAdd} label={plugin.string.AddState} on:click={addState} />
{/if}
@@ -41,6 +41,7 @@
import Navigator from './Navigator.svelte'
import TransitionPresenter from './TransitionPresenter.svelte'
import TriggerPresenter from './TriggerPresenter.svelte'
import { SortableList } from '@hcengineering/view-resources'
export let _id: Ref<Transition>
export let visibleSecondNav: boolean = true
@@ -136,6 +137,11 @@
function edit (): void {
$settingsStore = { id: _id, component: AsideTransitionEditor, props: { process, transition: value } }
}
async function moveHadler (): Promise<void> {
if (value === undefined) return
await client.update(value, { actions: value.actions })
}
</script>
<div class="hulyComponent-content__container columns">
@@ -169,20 +175,22 @@
<div class="label w-full p-4">
<Label label={plugin.string.Actions} />
</div>
{#each value.actions as action}
<Button
justify="left"
kind="ghost"
width="100%"
on:click={() => {
editAction(action)
}}
>
<svelte:fragment slot="content">
<ActionPresenter {action} {process} readonly={false} />
</svelte:fragment>
</Button>
{/each}
<SortableList bind:items={value.actions} on:move={moveHadler}>
<svelte:fragment slot="object" let:value>
<Button
justify="left"
kind="ghost"
width="100%"
on:click={() => {
editAction(value)
}}
>
<svelte:fragment slot="content">
<ActionPresenter action={value} {process} readonly={false} />
</svelte:fragment>
</Button>
</svelte:fragment>
</SortableList>
<Button
kind={'ghost'}
width={'100%'}
@@ -20,7 +20,7 @@
import AddTransitionPopup from './AddTransitionPopup.svelte'
import TransitionPresenter from './TransitionPresenter.svelte'
import TriggerPresenter from './TriggerPresenter.svelte'
import { SortableList } from '@hcengineering/view-resources'
import { SortableDocList } from '@hcengineering/view-resources'
export let process: Process
export let readonly: boolean
@@ -47,7 +47,7 @@
<div class="header w-full p-4">
<Label label={plugin.string.Transitions} />
</div>
<SortableList _class={plugin.class.Transition} query={{ process: process._id }} isAddButtonHidden>
<SortableDocList _class={plugin.class.Transition} query={{ process: process._id }}>
<svelte:fragment slot="object" let:value>
{@const transition = toTransition(value)}
<Button
@@ -66,7 +66,7 @@
</svelte:fragment>
</Button>
</svelte:fragment>
</SortableList>
</SortableDocList>
{#if !readonly}
<Button kind={'ghost'} width={'100%'} icon={IconAdd} label={plugin.string.AddTransition} on:click={addTransition} />
{/if}
@@ -22,7 +22,8 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'
import {
QMSInlineCommentMark,
type QMSInlineCommentMarkOptions,
findQMSInlineCommentMark
findQMSInlineCommentMark,
getMarkUuid
} from './qmsInlineCommentMark'
export enum CommentHighlightType {
@@ -233,24 +234,24 @@ const createDecorations = (
): DecorationSet => {
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
const qmsInlineCommentMark = findQMSInlineCommentMark(node)
doc.descendants((descendant, pos) => {
descendant.marks.forEach((mark) => {
const uuid = getMarkUuid(mark)
if (uuid !== undefined) {
const attributes = generateAttributes(uuid, options)
if (attributes === null || attributes === undefined) {
return
}
if (qmsInlineCommentMark !== null && qmsInlineCommentMark !== undefined) {
const nodeUuid = qmsInlineCommentMark.attrs[QMSInlineCommentMark.name]
const attributes = generateAttributes(nodeUuid, options)
if (attributes === null || attributes === undefined) {
return
// the first pos does not contain the mark, so we need to add 1 (pos + 1) to get the correct range
const range = getMarkRange(doc.resolve(pos + 1), markType, mark.attrs)
if (!isRange(range)) {
return
}
decorations.push(Decoration.inline(range.from, range.to, attributes))
}
// the first pos does not contain the mark, so we need to add 1 (pos + 1) to get the correct range
const range = getMarkRange(doc.resolve(pos + 1), markType, qmsInlineCommentMark.attrs)
if (!isRange(range)) {
return
}
decorations.push(Decoration.inline(range.from, range.to, attributes))
}
})
})
return DecorationSet.create(doc, decorations)
@@ -13,25 +13,16 @@
// limitations under the License.
//
import {
type CommandProps,
type Editor,
Mark,
getMarkRange,
getMarkType,
getMarksBetween,
mergeAttributes
} from '@tiptap/core'
import { type Node, type Mark as ProseMirrorMark } from '@tiptap/pm/model'
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { type CommandProps, type Editor, Mark, getMarkRange, mergeAttributes } from '@tiptap/core'
import { type Node, type Mark as ProseMirrorMark, Fragment, Slice } from '@tiptap/pm/model'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
export const qmsInlineCommentMarkName = 'node-uuid'
export const nodeElementQuerySelector = (nodeUuid: string): string => `span[${qmsInlineCommentMarkName}='${nodeUuid}']`
export interface QMSInlineCommentMarkOptions {
HTMLAttributes: Record<string, any>
onNodeSelected?: (uuid: string | null) => void
onNodeClicked?: (uuid: string) => void
onNodeClicked?: (uuid: string | string[]) => void
}
declare module '@tiptap/core' {
@@ -43,32 +34,6 @@ declare module '@tiptap/core' {
}
}
export interface QMSInlineCommentMarkStorage {
activeQMSInlineComment: string | null
}
const findSelectionQMSInlineCommentMark = (state: EditorState): ProseMirrorMark | undefined => {
const { doc, selection } = state
if (selection === null || selection === undefined) {
return
}
let nodeUuidMark: ProseMirrorMark | undefined
for (const range of selection.ranges) {
if (nodeUuidMark === undefined) {
doc.nodesBetween(range.$from.pos, range.$to.pos, (node) => {
if (nodeUuidMark !== undefined) {
return false
}
nodeUuidMark = findQMSInlineCommentMark(node)
})
}
}
return nodeUuidMark
}
export const findQMSInlineCommentMark = (node: Node): ProseMirrorMark | undefined => {
if (node === null || node === undefined) {
return
@@ -85,13 +50,14 @@ export function getNodeElement (editor: Editor, uuid: string): Element | null {
return editor.view.dom.querySelector(nodeElementQuerySelector(uuid))
}
export function selectNode (editor: Editor, uuid: string): void {
export function selectNode (editor: Editor, uuid: string): boolean {
if (editor === undefined) {
return
return false
}
const { doc, schema, tr } = editor.view.state
let foundNode = false
const { doc, schema, tr } = editor.view.state
doc.descendants((node, pos) => {
if (foundNode) {
return false
@@ -118,12 +84,17 @@ export function selectNode (editor: Editor, uuid: string): void {
editor?.view.dispatch(tr.setSelection(new TextSelection($start, $end)))
editor.commands.focus()
})
return foundNode
}
export const QMSInlineCommentMark = Mark.create<QMSInlineCommentMarkOptions, QMSInlineCommentMarkStorage>({
export const QMSInlineCommentMark = Mark.create<QMSInlineCommentMarkOptions>({
name: qmsInlineCommentMarkName,
exitable: true,
inclusive: false,
// set to empty string to allow multiple marks of the same type
// https://prosemirror.net/docs/ref/#model.MarkSpec.excludes
excludes: '',
addOptions () {
return {
HTMLAttributes: {}
@@ -161,37 +132,28 @@ export const QMSInlineCommentMark = Mark.create<QMSInlineCommentMarkOptions, QMS
addProseMirrorPlugins () {
const options = this.options
const storage: QMSInlineCommentMarkStorage = this.storage
const plugins = [
...(this.parent?.() ?? []),
new Plugin({
key: new PluginKey('handle-qms-inline-comment-click-plugin'),
props: {
handleClick (view, pos) {
const from = Math.max(0, pos - 1)
const to = Math.min(view.state.doc.content.size, pos + 1)
const markRanges =
getMarksBetween(from, to, view.state.doc)?.filter(
(markRange) =>
markRange.mark.type.name === qmsInlineCommentMarkName && markRange.from <= pos && markRange.to >= pos
) ?? []
let nodeUuid: string | null = null
const marks = view.state.selection.$head
.marks()
.filter((mark) => mark.type.name === qmsInlineCommentMarkName)
if (markRanges.length > 0) {
nodeUuid = markRanges[0].mark.attrs[qmsInlineCommentMarkName]
}
const uuids = marks
.map((mark) => mark.attrs[qmsInlineCommentMarkName])
.filter((uuid) => uuid !== null && uuid !== undefined && uuid.length > 0)
if (nodeUuid !== null) {
options.onNodeClicked?.(nodeUuid)
}
if (storage.activeQMSInlineComment !== nodeUuid) {
storage.activeQMSInlineComment = nodeUuid
options.onNodeSelected?.(storage.activeQMSInlineComment)
if (uuids.length !== 0) {
options.onNodeClicked?.(uuids)
}
}
}
})
}),
QmsInlineCommentPastePlugin()
]
return plugins
@@ -202,14 +164,9 @@ export const QMSInlineCommentMark = Mark.create<QMSInlineCommentMarkOptions, QMS
setQMSInlineCommentMark:
(uuid: string) =>
({ commands, state }: CommandProps) => {
const { doc, selection } = state
if (selection.empty) {
if (state.selection.empty) {
return false
}
if (doc.rangeHasMark(selection.from, selection.to, getMarkType(qmsInlineCommentMarkName, state.schema))) {
return false
}
return commands.setMark(this.name, { [qmsInlineCommentMarkName]: uuid })
},
unsetQMSInlineCommentMark:
@@ -217,24 +174,68 @@ export const QMSInlineCommentMark = Mark.create<QMSInlineCommentMarkOptions, QMS
({ commands }: CommandProps) =>
commands.unsetMark(this.name)
}
},
addStorage () {
return {
activeQMSInlineComment: null
}
},
onSelectionUpdate () {
const activeQMSInlineCommentMark = findSelectionQMSInlineCommentMark(this.editor.state)
const activeQMSInlineComment =
activeQMSInlineCommentMark !== null && activeQMSInlineCommentMark !== undefined
? activeQMSInlineCommentMark.attrs[qmsInlineCommentMarkName]
: null
if (this.storage.activeQMSInlineComment !== activeQMSInlineComment) {
this.storage.activeQMSInlineComment = activeQMSInlineComment
this.options.onNodeSelected?.(this.storage.activeQMSInlineComment)
}
}
})
function removeMarkFromNode (node: Node, name: string): Node {
if (node.isText) {
return node.mark(node.marks.filter((mark) => mark.type.name !== name))
}
if (node.content.size > 0) {
const nodes: Node[] = []
node.content.forEach((child) => {
nodes.push(removeMarkFromNode(child, name))
})
return node.copy(Fragment.fromArray(nodes))
}
return node
}
export function getMarkUuid (mark: ProseMirrorMark): string | undefined {
return mark.type.name === qmsInlineCommentMarkName ? mark.attrs[qmsInlineCommentMarkName] : undefined
}
export function QmsInlineCommentPastePlugin (): Plugin {
return new Plugin({
key: new PluginKey('qms-inline-comment-paste-plugin'),
props: {
transformPasted: (slice, view) => {
const pastedUuids = new Set<string>()
slice.content.forEach((node) => {
node.descendants((descendant) => {
descendant.marks.forEach((mark) => {
const uuid = getMarkUuid(mark)
if (uuid !== undefined) {
pastedUuids.add(uuid)
}
})
})
})
let hasDuplicatedUuids = false
view.state.doc.descendants((node) => {
if (hasDuplicatedUuids) return false
for (const mark of node.marks) {
const uuid = getMarkUuid(mark)
if (uuid !== undefined && pastedUuids.has(uuid)) {
hasDuplicatedUuids = true
break
}
}
})
if (hasDuplicatedUuids) {
const nodes: Node[] = []
slice.content.forEach((node) => {
nodes.push(removeMarkFromNode(node, qmsInlineCommentMarkName))
})
return new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd)
}
return slice
}
}
})
}
+1 -2
View File
@@ -95,8 +95,7 @@ export {
QMSInlineCommentMark as NodeUuidExtension,
qmsInlineCommentMarkName as nodeUuidName,
selectNode,
type QMSInlineCommentMarkOptions as NodeUuidOptions,
type QMSInlineCommentMarkStorage as NodeUuidStorage
type QMSInlineCommentMarkOptions as NodeUuidOptions
} from './components/extension/qms/qmsInlineCommentMark'
export { referenceConfig, ReferenceExtension } from './components/extension/reference'
export { type Provider } from './provider/types'
@@ -0,0 +1,107 @@
<!--
// Copyright © 2022 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 { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, SortingOrder } from '@hcengineering/core'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { DocWithRank, makeRank } from '@hcengineering/task'
import { IconSize } from '@hcengineering/ui'
import { SvelteComponent } from 'svelte'
import { getListItemPresenter, getObjectPresenter } from '../../utils'
import SortableList from './SortableList.svelte'
export let _class: Ref<Class<Doc>>
export let label: IntlString | undefined = undefined
export let query: DocumentQuery<Doc> = {}
export let queryOptions: FindOptions<Doc> | undefined = undefined
export let presenterProps: Record<string, any> = {}
export let direction: 'row' | 'column' = 'column'
export let flipDuration = 200
export let itemsCount = 0
export let icon: Asset | undefined = undefined
export let iconSize: IconSize = 'small'
const SORTING_ORDER = SortingOrder.Ascending
const client = getClient()
const hierarchy = client.getHierarchy()
const itemsQuery = createQuery()
let isPresenterLoading = false
let areItemsloading = true
let areItemsSorting = false
let presenter: typeof SvelteComponent | undefined
let items: Doc[] = []
async function updatePresenter (classRef: Ref<Class<Doc>>) {
try {
isPresenterLoading = true
const listItemPresenter = await getListItemPresenter(client, classRef)
if (listItemPresenter) {
presenter = await getResource(listItemPresenter)
return
}
const objectModel = await getObjectPresenter(client, classRef, { key: '' })
if (objectModel?.presenter) {
presenter = objectModel.presenter
}
} finally {
isPresenterLoading = false
}
}
function updateItems (newItems: FindResult<Doc>): void {
items = newItems
areItemsloading = false
}
$: !$$slots.object && updatePresenter(_class)
$: itemsQuery.query(_class, query, updateItems, {
...(isSortable ? { sort: { rank: SORTING_ORDER } } : {}),
...(queryOptions ?? {}),
limit: Math.max(queryOptions?.limit ?? 0, 200)
})
$: isSortable = hierarchy.getAllAttributes(_class).has('rank')
async function handleMove (
e: CustomEvent<{ item: DocWithRank, prev: DocWithRank | undefined, next: DocWithRank | undefined }>
): Promise<void> {
if (!isSortable) return
const { item, prev, next } = e.detail
const sortingOrder = queryOptions?.sort?.rank ?? SORTING_ORDER
const rank =
sortingOrder === SortingOrder.Ascending ? makeRank(prev?.rank, next?.rank) : makeRank(next?.rank, prev?.rank)
try {
areItemsSorting = true
await client.update(item, { rank })
} finally {
areItemsSorting = false
}
}
</script>
<SortableList {items} {label} {direction} {flipDuration} {icon} {iconSize} bind:itemsCount on:move={handleMove}>
<svelte:fragment slot="object" let:value>
{#if $$slots.object}
<slot name="object" {value} />
{:else if presenter}
<svelte:component this={presenter} {...presenterProps} {value} />
{/if}
</svelte:fragment>
</SortableList>
@@ -13,92 +13,24 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, SortingOrder } from '@hcengineering/core'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { DocWithRank, makeRank } from '@hcengineering/task'
import { Button, Component, Icon, IconAdd, IconSize, Label, Loading } from '@hcengineering/ui'
import view, { ObjectFactory } from '@hcengineering/view'
import { Asset, IntlString } from '@hcengineering/platform'
import { Icon, IconSize, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import { getListItemPresenter, getObjectPresenter } from '../../utils'
import { SvelteComponent } from 'svelte'
/*
How to use:
We must add presenter for the "_class" via "ListItemPresenter" / "AttributePresenter"
mixins or render the "object" slot to be able display the rows list.
To create a new items, we should add "ObjectFactory" mixin also.
We can create a custom list items or editor based on "SortableListItem"
Important: the "ObjectFactory" component must emit the "close" event
*/
export let _class: Ref<Class<Doc>>
export let items: any[]
export let label: IntlString | undefined = undefined
export let query: DocumentQuery<Doc> = {}
export let queryOptions: FindOptions<Doc> | undefined = undefined
export let presenterProps: Record<string, any> = {}
export let direction: 'row' | 'column' = 'column'
export let flipDuration = 200
export let isAddButtonHidden = false
export let isAddButtonDisabled = false
export let itemsCount = 0
export let icon: Asset | undefined = undefined
export let iconSize: IconSize = 'small'
const SORTING_ORDER = SortingOrder.Ascending
const client = getClient()
const hierarchy = client.getHierarchy()
const itemsQuery = createQuery()
let isPresenterLoading = false
let areItemsloading = true
let areItemsSorting = false
let presenter: typeof SvelteComponent | undefined
let objectFactory: ObjectFactory | undefined
let items: Doc[] = []
const areItemsSorting = false
let draggingIndex: number | null = null
let hoveringIndex: number | null = null
let isCreating = false
async function updatePresenter (classRef: Ref<Class<Doc>>) {
try {
isPresenterLoading = true
const listItemPresenter = await getListItemPresenter(client, classRef)
if (listItemPresenter) {
presenter = await getResource(listItemPresenter)
return
}
const objectModel = await getObjectPresenter(client, classRef, { key: '' })
if (objectModel?.presenter) {
presenter = objectModel.presenter
}
} finally {
isPresenterLoading = false
}
}
function updateObjectFactory (objectFactoryClassRef: Ref<Class<Doc>>) {
const objectFactoryClass = hierarchy.getClass(objectFactoryClassRef)
if (hierarchy.hasMixin(objectFactoryClass, view.mixin.ObjectFactory)) {
objectFactory = hierarchy.as(objectFactoryClass, view.mixin.ObjectFactory)
}
}
function updateItems (newItems: FindResult<Doc>): void {
items = newItems
areItemsloading = false
}
function handleDragStart (ev: DragEvent, itemIndex: number) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'
@@ -114,63 +46,38 @@
hoveringIndex = itemIndex
}
const dispatch = createEventDispatcher()
async function handleDrop (itemIndex: number) {
if (isSortable && items && draggingIndex !== null && draggingIndex !== itemIndex) {
const item = items[draggingIndex] as DocWithRank
if (draggingIndex !== null && draggingIndex !== itemIndex) {
const item = items[draggingIndex]
const [prev, next] = [
items[draggingIndex < itemIndex ? itemIndex : itemIndex - 1] as DocWithRank,
items[draggingIndex < itemIndex ? itemIndex + 1 : itemIndex] as DocWithRank
items[draggingIndex < itemIndex ? itemIndex : itemIndex - 1],
items[draggingIndex < itemIndex ? itemIndex + 1 : itemIndex]
]
const sortingOrder = queryOptions?.sort?.rank ?? SORTING_ORDER
const rank =
sortingOrder === SortingOrder.Ascending ? makeRank(prev?.rank, next?.rank) : makeRank(next?.rank, prev?.rank)
items.splice(itemIndex, 0, items.splice(draggingIndex, 1)[0])
try {
areItemsSorting = true
await client.update(item, { rank })
} finally {
areItemsSorting = false
}
dispatch('move', { item, prev, next, items })
}
resetDrag()
}
async function create () {
if (objectFactory?.create) {
const createFn = await getResource(objectFactory.create)
await createFn(query)
return
}
isCreating = true
}
function resetDrag () {
draggingIndex = null
hoveringIndex = null
}
$: !$$slots.object && updatePresenter(_class)
$: updateObjectFactory(_class)
$: itemsQuery.query(_class, query, updateItems, {
...(isSortable ? { sort: { rank: SORTING_ORDER } } : {}),
...(queryOptions ?? {}),
limit: Math.max(queryOptions?.limit ?? 0, 200)
})
$: isLoading = isPresenterLoading || areItemsloading
$: isSortable = hierarchy.getAllAttributes(_class).has('rank')
$: itemsCount = items?.length ?? 0
$: isVertical = direction === 'column'
$: isDraggable = isSortable && items.length > 1 && !areItemsSorting
$: isDraggable = items.length > 1 && !areItemsSorting
</script>
<div class="flex-col" class:w-full={isVertical}>
{#if label || !isAddButtonHidden}
{#if label}
<div class="flex mb-4">
{#if icon}
<div class="mr-2 flex-center">
@@ -184,25 +91,10 @@
</span>
</div>
{/if}
{#if !isAddButtonHidden}
<div class="ml-auto">
<Button
showTooltip={{ label: presentation.string.Add }}
disabled={isAddButtonDisabled || isLoading || !objectFactory}
width="min-content"
icon={IconAdd}
size="small"
kind="ghost"
on:click={create}
/>
</div>
{/if}
</div>
{/if}
{#if isLoading}
<Loading />
{:else if ($$slots.object ?? presenter) && items}
{#if $$slots.object && items}
<div class="flex-gap-1" class:flex-col={isVertical} class:flex={!isVertical} class:flex-wrap={!isVertical}>
{#each items as item, index (item._id)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -225,18 +117,9 @@
}}
on:dragend={resetDrag}
>
{#if $$slots.object}
<slot name="object" value={item} {isDraggable} />
{:else if presenter}
<svelte:component this={presenter} {isDraggable} {...presenterProps} value={item} />
{/if}
<slot name="object" value={item} {isDraggable} />
</div>
{/each}
{#if objectFactory?.component && isCreating}
<!-- Important: the "close" event must be specified -->
<Component is={objectFactory.component} props={query} showLoading on:close={() => (isCreating = false)} />
{/if}
</div>
{/if}
</div>
+2
View File
@@ -90,6 +90,7 @@ import YoutubePresenter from './components/linkPresenters/YoutubePresenter.svelt
import DividerPresenter from './components/list/DividerPresenter.svelte'
import GrowPresenter from './components/list/GrowPresenter.svelte'
import ListView from './components/list/ListView.svelte'
import SortableDocList from './components/list/SortableDocList.svelte'
import SortableList from './components/list/SortableList.svelte'
import SortableListItem from './components/list/SortableListItem.svelte'
import TreeElement from './components/navigator/TreeElement.svelte'
@@ -236,6 +237,7 @@ export {
ObjectIcon,
ObjectMention,
SortableList,
SortableDocList,
SortableListItem,
SpaceHeader,
SpacePresenter,
@@ -380,6 +380,37 @@ async function OnCardUpdate (ctx: TxUpdateDoc<Card>[], control: TriggerControl):
})
}
res.push(...(await updatePeers(control, doc, updateTx)))
return res
}
async function updatePeers (control: TriggerControl, doc: Card, updateTx: TxUpdateDoc<Card>): Promise<Tx[]> {
if (updateTx.space === core.space.DerivedTx) return []
const isDirect = control.hierarchy.isDerived(doc._class, communication.type.Direct)
const isThreadFromDirect = (doc.parentInfo ?? []).some((it) =>
control.hierarchy.isDerived(it._class, communication.type.Direct)
)
if (!isDirect && !isThreadFromDirect) return []
delete updateTx.operations.title
delete updateTx.operations.parentInfo
delete updateTx.operations.parent
delete updateTx.operations.$inc
const peers = (
(
await control.domainRequest(control.ctx, 'communication' as OperationDomain, {
findPeers: { params: { kind: 'card', cardId: doc._id } }
})
).value as CardPeer[]
).flatMap((it) => it.members)
const res: Tx[] = []
for (const peer of peers) {
res.push(control.txFactory.createTxUpdateDoc(doc._class, peer.extra.space, peer.cardId, updateTx.operations))
}
return res
}
+3 -5
View File
@@ -4,10 +4,8 @@ export CLIENT_SECRET="$POD_GITHUB_CLIENT_SECRET"
export PRIVATE_KEY="$POD_GITHUB_PRIVATE_KEY"
export SERVER_SECRET=secret
export ACCOUNTS_URL=http://localhost:3000
export COLLABORATOR_URL=http://localhost:3078
export MINIO_ACCESS_KEY=minioadminchmo
export MINIO_SECRET_KEY=minioadmin
export MINIO_ENDPOINT=localhost
export MONGO_URL=mongodb://localhost:27017
export COLLABORATOR_URL=ws://huly.local:3078
export STORAGE_CONFIG="datalake|http://huly.local:4030"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://huly.local:4318/v1/traces
rush bundle --to @hcengineering/pod-github
node $@ bundle/bundle.js $@
+76 -75
View File
@@ -214,6 +214,8 @@ export class PlatformWorker {
let oldErrors = ''
let sameErrors = 1
let lastTimeout: any
while (!this.canceled) {
let errors: string[] = []
try {
@@ -227,6 +229,7 @@ export class PlatformWorker {
this.triggerCheckWorkspaces = () => {
this.ctx.info('Workspaces check triggered')
this.triggerCheckWorkspaces = () => {}
clearTimeout(lastTimeout)
resolve()
}
if (errors.length > 0) {
@@ -320,9 +323,10 @@ export class PlatformWorker {
try {
;({ client } = await createPlatformClient(ctx, oldWorkspace, 30000))
await this.removeInstallationFromWorkspace(oldWorker, installationId)
await client.close()
} catch (err: any) {
ctx.error('failed to remove old installation from workspace', { workspace: oldWorkspace, installationId })
} finally {
await client?.close()
}
}
}
@@ -944,26 +948,18 @@ export class PlatformWorker {
checkedWorkspaces = new Set<string>()
async checkWorkspaceIsActive (
workspace: WorkspaceUuid,
workspaceInfo?: WorkspaceInfoWithStatus,
needRecheck = false
): Promise<{ workspaceInfo: WorkspaceInfoWithStatus | undefined, needRecheck: boolean }> {
if (workspaceInfo === undefined && needRecheck) {
const token = generateToken(systemAccountUuid, workspace, { service: 'github', mode: 'github' })
workspaceInfo = await getAccountClient(config.AccountsURL, token).getWorkspaceInfo()
}
checkWorkspaceIsActive (workspace: WorkspaceUuid, workspaceInfo: WorkspaceInfoWithStatus): boolean {
if (workspaceInfo?.uuid === undefined) {
this.ctx.error('No workspace exists for workspaceId', { workspace })
return { workspaceInfo: undefined, needRecheck: false }
return false
}
if (workspaceInfo?.isDisabled === true || isDeletingMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is disabled', { workspace })
return { workspaceInfo: undefined, needRecheck: false }
return false
}
if (!isActiveMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace, mode: workspaceInfo?.mode })
return { workspaceInfo: undefined, needRecheck: true }
return true
}
const lastVisit = (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000) // In days
@@ -973,9 +969,38 @@ export class PlatformWorker {
this.checkedWorkspaces.add(workspace)
this.ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
}
return { workspaceInfo: undefined, needRecheck: true }
return true
}
return { workspaceInfo, needRecheck: true }
return false
}
checkReconnect (workspace: WorkspaceUuid, event: ClientConnectEvent, worker: GithubWorker): void {
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
void this.clients
.get(workspace)
?.refreshClient(event === ClientConnectEvent.Upgraded)
?.catch((err) => {
worker.ctx.error('Failed to refresh', { error: err })
})
}
// We need to check if workspace is inactive
const token = generateToken(systemAccountUuid, workspace, { service: 'github', mode: 'github' })
getAccountClient(config.AccountsURL, token)
.getWorkspaceInfo()
.then((wsInfo) => {
const res = this.checkWorkspaceIsActive(workspace, wsInfo)
if (!res) {
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close().catch((err) => {
this.ctx.error('Failed to close workspace', { workspace, error: err })
})
}
})
.catch((err) => {
this.ctx.error('Failed to check workspace is active', { workspace, error: err })
})
}
private async checkWorkspaces (): Promise<string[]> {
@@ -1008,6 +1033,7 @@ export class PlatformWorker {
this.ctx.info('connecting to workspace', { workspace: c, time: Date.now() - d.time, version: d.version })
}
}, 5000)
try {
const token = generateToken(systemAccountUuid, undefined, { service: 'github', mode: 'github' })
const infos = new Map(
@@ -1022,82 +1048,57 @@ export class PlatformWorker {
toDelete.delete(workspace)
continue
}
const returnedInfo = infos.get(workspace)
if (returnedInfo === undefined) {
rechecks.push(workspace)
continue
}
const needRecheck = this.checkWorkspaceIsActive(workspace, returnedInfo)
if (needRecheck) {
rechecks.push(workspace)
continue
}
await rateLimiter.add(async () => {
const { workspaceInfo, needRecheck } = await this.checkWorkspaceIsActive(workspace, infos.get(workspace))
if (workspaceInfo === undefined) {
if (needRecheck) {
rechecks.push(workspace)
}
return
}
try {
const branding = Object.values(this.brandingMap).find((b) => b.key === workspaceInfo?.branding) ?? null
const workerCtx = this.ctx.newChild('worker', { workspace: workspaceInfo.uuid }, { span: false })
const branding = Object.values(this.brandingMap).find((b) => b.key === returnedInfo?.branding) ?? null
const workerCtx = this.ctx.newChild('worker', { workspace: returnedInfo.uuid }, { span: false })
connecting.set(workspaceInfo.uuid, {
connecting.set(returnedInfo.uuid, {
time: Date.now(),
version: versionToString({
major: workspaceInfo.versionMajor,
minor: workspaceInfo.versionMinor,
patch: workspaceInfo.versionPatch
major: returnedInfo.versionMajor,
minor: returnedInfo.versionMinor,
patch: returnedInfo.versionPatch
})
})
workerCtx.info('************************* Register worker ************************* ', {
workspaceId: workspaceInfo.uuid,
workspaceUrl: workspaceInfo.url,
versionMajor: workspaceInfo.versionMajor,
versionMinor: workspaceInfo.versionMinor,
versionPatch: workspaceInfo.versionPatch,
mode: workspaceInfo.mode,
workspaceId: returnedInfo.uuid,
workspaceUrl: returnedInfo.url,
versionMajor: returnedInfo.versionMajor,
versionMinor: returnedInfo.versionMinor,
versionPatch: returnedInfo.versionPatch,
mode: returnedInfo.mode,
index: widx,
total: workspaces.length
})
let initialized = false
const worker = await GithubWorker.create(
this,
workerCtx,
this.installations,
{
dataId: workspaceInfo.dataId,
url: workspaceInfo.url,
uuid: workspaceInfo.uuid
dataId: returnedInfo.dataId,
url: returnedInfo.url,
uuid: returnedInfo.uuid
},
branding,
this.app,
this.storageAdapter,
(workspace, event) => {
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
void this.clients
.get(workspace)
?.refreshClient(event === ClientConnectEvent.Upgraded)
?.catch((err) => {
workerCtx.error('Failed to refresh', { error: err })
})
}
if (initialized) {
// We need to check if workspace is inactive
void this.checkWorkspaceIsActive(workspace, undefined, true)
.then((res) => {
if (res === undefined) {
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close().catch((err) => {
this.ctx.error('Failed to close workspace', { workspace, error: err })
})
}
})
.catch((err) => {
this.ctx.error('Failed to check workspace is active', { workspace, error: err })
})
}
}
this.storageAdapter
)
if (worker !== undefined) {
initialized = true
workerCtx.info('************************* Register worker Done ************************* ', {
workspaceId: workspaceInfo.uuid,
workspaceUrl: workspaceInfo.url,
workspaceId: returnedInfo.uuid,
workspaceUrl: returnedInfo.url,
index: widx,
total: workspaces.length
})
@@ -1107,12 +1108,12 @@ export class PlatformWorker {
workerCtx.info(
'************************* Failed Register worker, timeout or integrations removed *************************',
{
workspaceId: workspaceInfo.uuid,
workspaceUrl: workspaceInfo.url,
versionMajor: workspaceInfo.versionMajor,
versionMinor: workspaceInfo.versionMinor,
versionPatch: workspaceInfo.versionPatch,
lastVisit: (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (24 * 60 * 60 * 1000),
workspaceId: returnedInfo.uuid,
workspaceUrl: returnedInfo.url,
versionMajor: returnedInfo.versionMajor,
versionMinor: returnedInfo.versionMinor,
versionPatch: returnedInfo.versionPatch,
lastVisit: (Date.now() - (returnedInfo.lastVisit ?? 0)) / (24 * 60 * 60 * 1000),
index: widx,
total: workspaces.length
}
@@ -1124,7 +1125,7 @@ export class PlatformWorker {
this.ctx.info("Couldn't create WS worker", { workspace, error: e })
rechecks.push(workspace)
} finally {
connecting.delete(workspaceInfo.uuid)
connecting.delete(returnedInfo.uuid)
}
})
}
+13 -5
View File
@@ -1766,13 +1766,13 @@ export class GithubWorker implements IntegrationManager {
workspace: WorkspaceIds,
branding: Branding | null,
app: App,
storageAdapter: StorageAdapter,
reconnect: (workspaceId: WorkspaceUuid, event: ClientConnectEvent) => void
storageAdapter: StorageAdapter
): Promise<GithubWorker | undefined> {
ctx.info('Connecting to', { workspace })
let client: Client | undefined
let endpoint: string | undefined
let maitenanceState = false
let worker: GithubWorker | undefined
try {
;({ client, endpoint } = await createPlatformClient(
ctx,
@@ -1784,7 +1784,9 @@ export class GithubWorker implements IntegrationManager {
maitenanceState = true
throw new Error('Workspace in maintenance')
}
reconnect(workspace.uuid, event)
if (worker !== undefined) {
platformWorker.checkReconnect(workspace.uuid, event, worker)
}
}
))
ctx.info('connected to github', { workspace: workspace.uuid, endpoint })
@@ -1796,7 +1798,7 @@ export class GithubWorker implements IntegrationManager {
await GithubWorker.checkIntegrations(client, installations)
const worker = new GithubWorker(
worker = new GithubWorker(
ctx,
platformWorker.getRateLimiter(endpoint ?? ''),
platformWorker,
@@ -1808,10 +1810,16 @@ export class GithubWorker implements IntegrationManager {
branding
)
ctx.info('Init worker', { workspace: workspace.url, workspaceId: workspace.uuid })
void worker.init()
void worker.init().catch((err) => {
ctx.error('failed to init worker', { error: err, workspace: workspace.uuid })
void client?.close().catch((err) => {
ctx.error('failed to close client after init error', { error: err, workspace: workspace.uuid })
})
})
return worker
} catch (err: any) {
await client?.close()
void worker?.close()
if (maitenanceState) {
ctx.info('workspace in maintenance, schedule recheck', { workspace: workspace.uuid, endpoint })
return
+23 -2
View File
@@ -37,6 +37,7 @@ import process, {
parseError,
processError,
ProcessError,
ProcessToDo,
State,
Step,
Transition
@@ -169,6 +170,10 @@ async function processExecution (control: ProcessControl, record: ProcessMessage
if (transition !== undefined) {
await execute(execution, transition, control)
} else {
if (record.event === process.trigger.OnToDoRemove) {
const rollbackResult = await checkRollback(control, record, execution)
if (rollbackResult) return
}
control.ctx.info('No transition found for event', {
event: record.event,
execution: execution._id,
@@ -177,6 +182,23 @@ async function processExecution (control: ProcessControl, record: ProcessMessage
}
}
async function checkRollback (control: ProcessControl, record: ProcessMessage, execution: Execution): Promise<boolean> {
const todo = record.context.todo as ProcessToDo
if (!todo?.withRollback) return false
const rollbackTxes = execution.rollback.pop() ?? []
for (const tx of rollbackTxes) {
const timeout = setTimeout(() => {
control.ctx.warn('TX HANG', tx)
}, 30000)
await control.client.tx(tx)
clearTimeout(timeout)
}
await control.client.update(execution, {
rollback: execution.rollback
})
return true
}
async function getTriggerRollback (triggger: TriggerImpl, control: ProcessControl): Promise<Tx | undefined> {
if (triggger.rollbackFunc !== undefined) {
const rollbackFunc = await getResource(triggger.rollbackFunc)
@@ -284,13 +306,12 @@ async function executeTransition (execution: Execution, transition: Transition,
execution.rollback.push(rollback)
}
const executionUpdate = getDiffUpdate(execution, {
rollback: execution.rollback.length > 30 ? execution.rollback.slice(-30) : execution.rollback,
context: execution.context,
currentState: state._id,
status: isDone ? ExecutionStatus.Done : ExecutionStatus.Active,
error: null
})
executionUpdate.context = execution.context
executionUpdate.rollback = execution.rollback.length > 30 ? execution.rollback.slice(-30) : execution.rollback
res.push(client.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, executionUpdate))
res.push(
client.txFactory.createTxCreateDoc(process.class.ExecutionLog, execution.space, {