Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-03-25 16:46:20 +07:00
16 changed files with 481 additions and 77 deletions
+10 -2
View File
@@ -103,8 +103,16 @@ export function initStatisticsContext (
const handleError = (err: any): void => {
errorToSend++
if (errorToSend % 2 === 0) {
if (err.code !== 'UND_ERR_SOCKET') {
console.error(err)
const code = err?.code ?? err?.cause?.code
if (code !== 'UND_ERR_SOCKET') {
metricsContext.warn('Failed to send statistics', {
service: serviceName,
statsUrl,
code,
message: err?.message,
causeMessage: err?.cause?.message,
err
})
}
}
prev = undefined
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from '../markdown/escape'
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl, escapeTableCell } from '../markdown/escape'
describe('markdown/escape', () => {
describe('escapeMarkdownLinkText', () => {
@@ -48,8 +48,38 @@ describe('markdown/escape', () => {
expect(escapeMarkdownLinkUrl('https://example.com/path)')).toBe('https://example.com/path\\)')
})
it('escapes pipe', () => {
expect(escapeMarkdownLinkUrl('https://example.com/x|y')).toBe('https://example.com/x\\|y')
})
it('returns plain URL unchanged when no special chars', () => {
expect(escapeMarkdownLinkUrl('https://example.com')).toBe('https://example.com')
})
})
describe('escapeTableCellPreservingLink', () => {
it('escapes plain text pipe for table safety', () => {
expect(escapeTableCell('a|b')).toBe('a\\|b')
})
it('preserves markdown link and escapes pipes inside text and URL', () => {
const input = '[a|b](http://example.com/x|y)'
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y)')
})
it('escapes pipes even when value contains escaped characters', () => {
const input = '[a|b](http://example.com/x|y\\z)'
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y\\\\z)')
})
it('treats strings that do not end with `)` as plain text', () => {
const input = '[a|b](http://example.com/x|y'
expect(escapeTableCell(input)).toBe('\\[a\\|b\\](http://example.com/x\\|y')
})
it('returns empty string for null/undefined', () => {
expect(escapeTableCell(null)).toBe('')
expect(escapeTableCell(undefined)).toBe('')
})
})
})
@@ -29,5 +29,31 @@ export function escapeMarkdownLinkText (text: string): string {
* Escape markdown link URL (backslashes and closing parentheses)
*/
export function escapeMarkdownLinkUrl (url: string): string {
return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
return (
url
.replace(/\\/g, '\\\\')
.replace(/\)/g, '\\)')
// Pipes break markdown tables unless escaped, and are safe to escape in URLs.
.replace(/\|/g, '\\|')
)
}
/**
* Escape a markdown table cell while preserving `[text](url)` links.
*/
export function escapeTableCell (value: unknown): string {
const s = value == null ? '' : String(value)
const sep = s.indexOf('](')
const looksLikeMarkdownLink = s.startsWith('[') && sep !== -1 && s.endsWith(')')
if (!looksLikeMarkdownLink) {
return escapeMarkdownLinkText(s)
}
const rawText = s.slice(1, sep)
const rawUrl = s.slice(sep + 2, -1)
const escapedText = escapeMarkdownLinkText(rawText)
const escapedUrl = escapeMarkdownLinkUrl(rawUrl)
return `[${escapedText}](${escapedUrl})`
}
@@ -28,7 +28,7 @@ import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } f
import { formatValue } from '../formatter'
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
import { escapeMarkdownLinkText } from './escape'
import { escapeTableCell } from './escape'
import { createMarkdownLink } from './link'
async function preloadRefLookups (
@@ -253,10 +253,7 @@ export async function buildMarkdownTableFromDocs (
const linkValue = await createMarkdownLink(hierarchy, card, value)
row.push(linkValue)
} else {
// If formatter already returned a markdown link, do not escape it again.
const looksLikeMarkdownLink =
typeof value === 'string' && value.startsWith('[') && value.includes('](') && value.endsWith(')')
row.push(looksLikeMarkdownLink ? value : escapeMarkdownLinkText(value))
row.push(escapeTableCell(value))
}
}
rows.push(row)
@@ -378,7 +375,7 @@ export async function buildRelationshipTableMarkdown (
if (isDocumentTitle) {
value = await createMarkdownLink(hierarchy, docToUse, value)
} else {
value = escapeMarkdownLinkText(value)
value = escapeTableCell(value)
}
row[attrIndex] = value
+3 -1
View File
@@ -13,7 +13,9 @@
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
"_phase:validate": "compile validate"
"_phase:validate": "compile validate",
"test": "jest --passWithNoTests --silent",
"_phase:test": "jest --passWithNoTests --silent"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
@@ -0,0 +1,155 @@
import { createRemoteProvider } from './utils'
const baseDestroy = jest.fn()
let lastConfig: any
jest.mock('@hcengineering/core', () => ({
generateId: () => 'guid-1'
}))
jest.mock('@hcengineering/presentation', () => ({
__esModule: true,
default: {
metadata: {
Token: 'token',
CollaboratorUrl: 'collaboratorUrl',
WorkspaceUuid: 'workspaceUuid'
}
}
}))
jest.mock('@hcengineering/platform', () => {
const getMetadata = jest.fn()
const setPlatformStatus = jest.fn()
class Status {
severity: any
code: any
params: any
constructor (severity: any, code: any, params: any) {
this.severity = severity
this.code = code
this.params = params
}
}
return {
OK: { code: 'OK' },
Severity: { ERROR: 'ERROR' },
Status,
getMetadata,
setPlatformStatus
}
})
jest.mock('@hcengineering/collaborator-client', () => {
const encodeDocumentId = jest.fn()
return { encodeDocumentId }
})
jest.mock('../plugin', () => ({
__esModule: true,
default: {
string: {
CannotConnectToCollaborationService: 'CannotConnectToCollaborationService'
}
}
}))
jest.mock('./hocuspocus', () => ({
HocuspocusCollabProvider: class {
destroy: () => void
constructor (config: any) {
lastConfig = config
this.destroy = baseDestroy
}
}
}))
describe('createRemoteProvider reconnect grace behavior', () => {
const RECONNECT_GRACE_MS = 5000
const platformMock = jest.requireMock('@hcengineering/platform')
const collaboratorClientMock = jest.requireMock('@hcengineering/collaborator-client')
beforeEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
baseDestroy.mockReset()
lastConfig = undefined
platformMock.getMetadata.mockImplementation((key: string) => {
if (key === 'token') return 'token-1'
if (key === 'collaboratorUrl') return 'wss://collab.example/ws'
if (key === 'workspaceUuid') return 'ws-1'
return undefined
})
collaboratorClientMock.encodeDocumentId.mockReturnValue('encoded-doc-id')
})
afterEach(() => {
jest.useRealTimers()
})
it('does not report error before grace timeout', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('starts grace timer once and does not postpone on repeated 1006 closes', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(2000)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(1000)
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
errorSpy.mockRestore()
})
it('clears pending error when reconnect succeeds in grace window', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1000)
lastConfig.onConnect()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
expect(platformMock.setPlatformStatus).toHaveBeenCalledWith({ code: 'OK' })
errorSpy.mockRestore()
})
it('clears pending error timer on provider destroy', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const provider = createRemoteProvider({} as any, 'doc-1' as any, null)
lastConfig.onClose({ event: { code: 1006 } })
void provider.destroy()
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
expect(baseDestroy).toHaveBeenCalledTimes(1)
expect(errorSpy).not.toHaveBeenCalled()
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
})
@@ -24,6 +24,9 @@ import plugin from '../plugin'
import { HocuspocusCollabProvider } from './hocuspocus'
import { type Provider } from './types'
/** After idle/tab sleep the WS often closes with 1006; Hocuspocus reconnects. Defer user-visible errors. */
const COLLABORATOR_RECONNECT_GRACE_MS = 5000
function getDocumentId (doc: CollaborativeDoc): string {
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
return encodeDocumentId(workspace, doc)
@@ -35,6 +38,15 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
const documentId = getDocumentId(doc)
let reconnectGraceTimeout: ReturnType<typeof setTimeout> | undefined
const clearReconnectGrace = (): void => {
if (reconnectGraceTimeout !== undefined) {
clearTimeout(reconnectGraceTimeout)
reconnectGraceTimeout = undefined
}
}
const provider = new HocuspocusCollabProvider({
url: collaboratorUrl,
name: documentId,
@@ -42,17 +54,29 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
token,
parameters: { content },
onConnect: () => {
clearReconnectGrace()
void setPlatformStatus(OK)
},
onClose: (data) => {
if (data.event.code === 1006) {
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
if (reconnectGraceTimeout === undefined) {
reconnectGraceTimeout = setTimeout(() => {
reconnectGraceTimeout = undefined
console.error('Failed to connect to collaborator', data.event)
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
void setPlatformStatus(status)
}, COLLABORATOR_RECONNECT_GRACE_MS)
}
}
}
})
const baseDestroy = provider.destroy.bind(provider)
provider.destroy = (): void => {
clearReconnectGrace()
baseDestroy()
}
return provider
}
@@ -10,7 +10,9 @@
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
"_phase:validate": "compile validate"
"_phase:validate": "compile validate",
"svelte-check": "do-svelte-check",
"_phase:svelte-check": "do-svelte-check"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
@@ -1,7 +1,7 @@
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import core, { ClassifierKind, Ref, WithLookup, generateId } from '@hcengineering/core'
import { getEmbeddedLabel, getMetadata, translate } from '@hcengineering/platform'
import core, { Ref, WithLookup, generateId } from '@hcengineering/core'
import { getMetadata, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import task, { TaskType, updateProjectType, type TaskStatusFactory } from '@hcengineering/task'
import tracker, { Project, createStatesData } from '@hcengineering/tracker'
@@ -63,13 +63,6 @@
if (!client.getHierarchy().hasMixin(projectInst, github.mixin.GithubProject)) {
// We need to add GithubProject mixin
const mixinId = await getClient().createDoc(core.class.Mixin, core.space.Model, {
extends: github.mixin.GithubIssue,
kind: ClassifierKind.MIXIN,
label: getEmbeddedLabel(projectInst.name),
hidden: false,
icon: github.icon.Github
})
await getClient().createMixin(
projectInst._id,
tracker.class.Project,
@@ -77,9 +70,7 @@
github.mixin.GithubProject,
{
integration: integration._id,
repositories: [],
mixinClass: mixinId,
mappings: []
repositories: []
}
)
}
@@ -3,18 +3,17 @@
//
-->
<script lang="ts">
import { PersonAccount } from '@hcengineering/contact'
import { getCurrentAccount } from '@hcengineering/core'
import { createQuery, getClient, getFileUrl } from '@hcengineering/presentation'
import { Button, Chevron, Component, ExpandCollapse, Label } from '@hcengineering/ui'
import diffview from '@hcengineering/diffview'
import { GithubPatch, GithubPullRequest, GithubPullRequestReview } from '@hcengineering/github'
import github from '../plugin'
import { getCurrentEmployee } from '@hcengineering/contact'
export let pullRequest: GithubPullRequest
const me = getCurrentAccount() as PersonAccount
const me = getCurrentEmployee()
let isCollapsed = true
@@ -37,6 +36,7 @@
}
$: hasPatch = patch !== undefined && patchText !== ''
$: changedFilesCount = patchText === '' ? 0 : (patchText.match(/^diff --git /gm)?.length ?? 0)
let review: GithubPullRequestReview | undefined
@@ -46,7 +46,7 @@
github.class.GithubPullRequestReview,
{
attachedTo: pullRequest._id,
author: me.person
author: me
},
(res) => {
;[review] = res
@@ -60,7 +60,7 @@
async function handleFileViewed (fileName: string, sha: string, viewed: boolean): Promise<void> {
const current = await client.findOne(github.class.GithubPullRequestReview, {
attachedTo: pullRequest._id,
author: me.person
author: me
})
const files = current?.files ?? []
@@ -73,7 +73,7 @@
files.push({ fileName, sha })
}
if (current) {
if (current != null) {
await client.update(current, { files })
} else {
await client.addCollection(
@@ -82,7 +82,7 @@
pullRequest._id,
github.class.GithubPullRequest,
'reviewsVisual',
{ author: me.person, files }
{ author: me, files }
)
}
}
@@ -106,7 +106,7 @@
fill={'var(--caption-color)'}
marginRight={'.375rem'}
/>
<Label label={github.string.ChangedFiles} params={{ files: pullRequest.files }} />
<Label label={github.string.ChangedFiles} params={{ files: changedFilesCount }} />
</svelte:fragment>
</Button>
</div>
@@ -119,7 +119,7 @@
props={{ patch: patchText, viewed: viewedFiles }}
on:change={(evt) => {
const { fileName, sha, viewed } = evt.detail
handleFileViewed(fileName, sha, viewed)
void handleFileViewed(fileName, sha, viewed)
}}
/>
</div>
@@ -21,7 +21,7 @@
</script>
<HyperlinkEditor
value={repository?.repository?.html_url ?? repository?.htmlURL}
value={repository?.htmlURL ?? ''}
placeholder={getEmbeddedLabel(repository?.name ?? '')}
title={repository?.name ?? ''}
readonly
@@ -37,7 +37,7 @@
export let label: IntlString = github.string.AssignRepository
export let showIcon: boolean = false
$: repository = $integrationRepositories.get(value)
$: repository = value != null ? $integrationRepositories.get(value) : undefined
let selectedRepository: GithubIntegrationRepository | undefined
@@ -3,7 +3,7 @@
//
-->
<script lang="ts">
import core, { PersonId, Ref, WithLookup, getCurrentAccount } from '@hcengineering/core'
import core, { Ref, WithLookup, getCurrentAccount } from '@hcengineering/core'
import { GithubPullRequest, GithubReviewComment, GithubReviewThread } from '@hcengineering/github'
import { ActivityMessageHeader, ActivityMessageTemplate } from '@hcengineering/activity-resources'
@@ -82,8 +82,9 @@
}
}
const toRefPersonAccount = (account: PersonId): PersonId => account
const toRefPerson = (account?: Ref<Person>): Ref<Person> => account as Ref<Person>
function onDiffExpand (nextExpanded: boolean): void {
expanded = nextExpanded
}
</script>
<div
@@ -122,9 +123,7 @@
fileName: value.path,
expandable: value.isResolved,
expanded,
onExpand: (value) => {
expanded = value
}
onExpand: onDiffExpand
}}
/>
{/if}
@@ -0,0 +1,111 @@
import type { MeasureContext, WorkspaceInfoWithStatus, WorkspaceUuid } from '@hcengineering/core'
import { GithubWorkerWorkspaceState, getGithubWorkerState } from '../workspaceUtils'
const WS = '00000000-0000-0000-0000-000000000001' as WorkspaceUuid
const DAY_MS = 24 * 60 * 60 * 1000
function createMockCtx (): MeasureContext {
return {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn()
} as unknown as MeasureContext
}
function baseInfo (overrides: Partial<WorkspaceInfoWithStatus> = {}): WorkspaceInfoWithStatus {
return {
uuid: WS,
name: 'ws',
url: 'ws-url',
createdOn: 0,
versionMajor: 0,
versionMinor: 7,
versionPatch: 0,
mode: 'active',
processingAttemps: 0,
...overrides
}
}
describe('getGithubWorkerState', () => {
it('returns Skip and warns when uuid is absent', () => {
const ctx = createMockCtx()
const checked = new Set<string>()
const info = { ...baseInfo(), uuid: undefined as unknown as WorkspaceUuid }
const state = getGithubWorkerState(ctx, WS, info, 3, checked)
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
expect(ctx.warn).toHaveBeenCalled()
})
it('returns Skip when workspace is disabled', () => {
const ctx = createMockCtx()
const state = getGithubWorkerState(ctx, WS, baseInfo({ isDisabled: true, mode: 'active' }), 3, new Set())
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
expect(ctx.info).toHaveBeenCalled()
})
it.each(['pending-deletion', 'deleting', 'deleted'] as const)('returns Skip for mode %s', (mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
})
it.each(['archived', 'archiving-pending-backup', 'archiving-clean'] as const)(
'returns Skip for archiving %s',
(mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
}
)
it.each(['upgrading', 'creating', 'pending-creation'] as const)('returns Wait for mode %s', (mode) => {
const ctx = createMockCtx()
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Wait)
expect(ctx.warn).toHaveBeenCalled()
})
it('returns Wait when last visit exceeds inactivity interval', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 4 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Wait
)
})
it('returns Connect when within inactivity interval', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 2 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Connect
)
})
it('does not apply inactivity gate when interval is 0', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
const lastVisit = nowMs - 365 * DAY_MS
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 0, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Connect
)
})
it('missing lastVisit uses epoch → Wait inactive when interval > 0', () => {
const ctx = createMockCtx()
const nowMs = 1_700_000_000_000
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active' }), 3, new Set(), nowMs)).toBe(
GithubWorkerWorkspaceState.Wait
)
})
it('logs inactive warning only once per workspace id', () => {
const ctx = createMockCtx()
const checked = new Set<string>()
const nowMs = 1_700_000_000_000
const info = baseInfo({ mode: 'active', lastVisit: nowMs - 10 * DAY_MS })
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
expect(ctx.warn).toHaveBeenCalledTimes(1)
})
})
+23 -33
View File
@@ -11,8 +11,6 @@ import core, {
Client,
ClientConnectEvent,
DocumentUpdate,
isActiveMode,
isDeletingMode,
MeasureContext,
PersonId,
RateLimiter,
@@ -46,6 +44,7 @@ import { type StorageAdapter } from '@hcengineering/server-core'
import { join } from 'path'
import { createPlatformClient } from './client'
import config from './config'
import { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
import { registerLoaders } from './loaders'
import { createNotification } from './notifications'
import { errorToObj } from './sync/utils'
@@ -68,6 +67,8 @@ interface IntegrationDataValue {
installationId: number | number[]
}
export { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
export class PlatformWorker {
private readonly clients = new Map<WorkspaceUuid, GithubWorker>()
@@ -948,32 +949,6 @@ export class PlatformWorker {
checkedWorkspaces = new Set<string>()
checkWorkspaceIsActive (workspace: WorkspaceUuid, workspaceInfo: WorkspaceInfoWithStatus): boolean {
if (workspaceInfo?.uuid === undefined) {
this.ctx.error('No workspace exists for workspaceId', { workspace })
return false
}
if (workspaceInfo?.isDisabled === true || isDeletingMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is disabled', { workspace })
return false
}
if (!isActiveMode(workspaceInfo?.mode)) {
this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace, mode: workspaceInfo?.mode })
return true
}
const lastVisit = (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000) // In days
if (config.WorkspaceInactivityInterval > 0 && lastVisit > config.WorkspaceInactivityInterval) {
if (!this.checkedWorkspaces.has(workspace)) {
this.checkedWorkspaces.add(workspace)
this.ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
}
return true
}
return false
}
checkReconnect (workspace: WorkspaceUuid, event: ClientConnectEvent, worker: GithubWorker): void {
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
void this.clients
@@ -989,9 +964,15 @@ export class PlatformWorker {
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 })
const state = getGithubWorkerState(
this.ctx,
workspace,
wsInfo,
config.WorkspaceInactivityInterval,
this.checkedWorkspaces
)
if (state === GithubWorkerWorkspaceState.Skip) {
this.ctx.warn('Github worker state is skip, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close().catch((err) => {
this.ctx.error('Failed to close workspace', { workspace, error: err })
@@ -1053,11 +1034,20 @@ export class PlatformWorker {
rechecks.push(workspace)
continue
}
const needRecheck = this.checkWorkspaceIsActive(workspace, returnedInfo)
if (needRecheck) {
const state = getGithubWorkerState(
this.ctx,
workspace,
returnedInfo,
config.WorkspaceInactivityInterval,
this.checkedWorkspaces
)
if (state === GithubWorkerWorkspaceState.Wait) {
rechecks.push(workspace)
continue
}
if (state === GithubWorkerWorkspaceState.Skip) {
continue
}
await rateLimiter.add(async () => {
try {
const branding = Object.values(this.brandingMap).find((b) => b.key === returnedInfo?.branding) ?? null
@@ -0,0 +1,69 @@
//
// Copyright © 2026 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 {
isActiveMode,
isArchivingMode,
isDeletingMode,
type MeasureContext,
type WorkspaceInfoWithStatus,
type WorkspaceUuid
} from '@hcengineering/core'
/** Whether this pod should run a GithubWorker for the workspace. */
export enum GithubWorkerWorkspaceState {
/** Active workspace — connect and run worker. */
Connect = 'connect',
/** Not ready yet (upgrade, creation, …) or too inactive — recheck later; keep existing worker. */
Wait = 'wait',
/** Disabled, deleting, deleted, or archiving — do not run; drop worker if present. */
Skip = 'skip'
}
export function getGithubWorkerState (
ctx: MeasureContext,
workspace: WorkspaceUuid,
workspaceInfo: WorkspaceInfoWithStatus,
inactivityIntervalDays: number,
checkedWorkspaces: Set<string>,
nowMs: number = Date.now()
): GithubWorkerWorkspaceState {
if (workspaceInfo?.uuid === undefined) {
ctx.warn('No workspace exists for workspaceId', { workspace })
return GithubWorkerWorkspaceState.Skip
}
if (workspaceInfo.isDisabled === true || isDeletingMode(workspaceInfo.mode) || isArchivingMode(workspaceInfo.mode)) {
ctx.info('Workspace is disabled, deleting, or archived — skipping github worker', {
workspace,
mode: workspaceInfo.mode,
isDisabled: workspaceInfo.isDisabled
})
return GithubWorkerWorkspaceState.Skip
}
if (!isActiveMode(workspaceInfo.mode)) {
ctx.warn('Workspace is in maintenance, skipping for now.', { workspace, mode: workspaceInfo.mode })
return GithubWorkerWorkspaceState.Wait
}
const lastVisitDays = (nowMs - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000)
if (inactivityIntervalDays > 0 && lastVisitDays > inactivityIntervalDays) {
if (!checkedWorkspaces.has(workspace)) {
checkedWorkspaces.add(workspace)
ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
}
return GithubWorkerWorkspaceState.Wait
}
return GithubWorkerWorkspaceState.Connect
}