From 801c60b8ceacd709840b30296ccf184bb5c5da71 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 09:36:44 +0700 Subject: [PATCH 1/5] Format stats error (#10681) Signed-off-by: Artem Savchenko --- foundations/server/packages/core/src/stats.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/foundations/server/packages/core/src/stats.ts b/foundations/server/packages/core/src/stats.ts index 4cfac0b184..dfc74531ca 100644 --- a/foundations/server/packages/core/src/stats.ts +++ b/foundations/server/packages/core/src/stats.ts @@ -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 From 5a9e02e6db3a19f7b44105ddfbbd7fc431c2f818 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 09:37:26 +0700 Subject: [PATCH 2/5] Do not display collaboration errors in case of reconnects (#10678) Signed-off-by: Artem Savchenko --- plugins/text-editor-resources/package.json | 4 +- .../src/provider/utils.test.ts | 155 ++++++++++++++++++ .../src/provider/utils.ts | 30 +++- 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 plugins/text-editor-resources/src/provider/utils.test.ts diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index 94dfc604c0..3d10ccdc64 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -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", diff --git a/plugins/text-editor-resources/src/provider/utils.test.ts b/plugins/text-editor-resources/src/provider/utils.test.ts new file mode 100644 index 0000000000..ce5bd8670b --- /dev/null +++ b/plugins/text-editor-resources/src/provider/utils.test.ts @@ -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() + }) +}) diff --git a/plugins/text-editor-resources/src/provider/utils.ts b/plugins/text-editor-resources/src/provider/utils.ts index 373f5de3d9..84f2771fbb 100644 --- a/plugins/text-editor-resources/src/provider/utils.ts +++ b/plugins/text-editor-resources/src/provider/utils.ts @@ -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 | 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 } From 37dda9ed11fcee042ac8c1e9d56828c00b4735c6 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 16:32:30 +0700 Subject: [PATCH 3/5] Fix markdown link escaping (#10685) * Fix markdown link escaping Signed-off-by: Artem Savchenko * Fix escape Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../src/__tests__/markdown.escape.test.ts | 32 ++++++++++++++++++- .../src/markdown/escape.ts | 28 +++++++++++++++- .../src/markdown/tableBuilder.ts | 9 ++---- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/plugins/converter-resources/src/__tests__/markdown.escape.test.ts b/plugins/converter-resources/src/__tests__/markdown.escape.test.ts index ee83407268..29926dcf9d 100644 --- a/plugins/converter-resources/src/__tests__/markdown.escape.test.ts +++ b/plugins/converter-resources/src/__tests__/markdown.escape.test.ts @@ -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('') + }) + }) }) diff --git a/plugins/converter-resources/src/markdown/escape.ts b/plugins/converter-resources/src/markdown/escape.ts index 00ea4f81d8..3175bf13eb 100644 --- a/plugins/converter-resources/src/markdown/escape.ts +++ b/plugins/converter-resources/src/markdown/escape.ts @@ -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})` } diff --git a/plugins/converter-resources/src/markdown/tableBuilder.ts b/plugins/converter-resources/src/markdown/tableBuilder.ts index 652c942e2d..16109337c9 100644 --- a/plugins/converter-resources/src/markdown/tableBuilder.ts +++ b/plugins/converter-resources/src/markdown/tableBuilder.ts @@ -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 From 466935d26a80e3de3da62189a7c23281bb41713d Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 16:33:28 +0700 Subject: [PATCH 4/5] Do not create Github worker for deleting workspace (#10661) Signed-off-by: Artem Savchenko --- .../src/__tests__/workspaceUtils.test.ts | 111 ++++++++++++++++++ services/github/pod-github/src/platform.ts | 56 ++++----- .../github/pod-github/src/workspaceUtils.ts | 69 +++++++++++ 3 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 services/github/pod-github/src/__tests__/workspaceUtils.test.ts create mode 100644 services/github/pod-github/src/workspaceUtils.ts diff --git a/services/github/pod-github/src/__tests__/workspaceUtils.test.ts b/services/github/pod-github/src/__tests__/workspaceUtils.test.ts new file mode 100644 index 0000000000..27a91d87a1 --- /dev/null +++ b/services/github/pod-github/src/__tests__/workspaceUtils.test.ts @@ -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 { + 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() + 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() + 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) + }) +}) diff --git a/services/github/pod-github/src/platform.ts b/services/github/pod-github/src/platform.ts index 7c7d6d4a75..edf1929209 100644 --- a/services/github/pod-github/src/platform.ts +++ b/services/github/pod-github/src/platform.ts @@ -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() @@ -948,32 +949,6 @@ export class PlatformWorker { checkedWorkspaces = new Set() - 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 diff --git a/services/github/pod-github/src/workspaceUtils.ts b/services/github/pod-github/src/workspaceUtils.ts new file mode 100644 index 0000000000..744eaf76d4 --- /dev/null +++ b/services/github/pod-github/src/workspaceUtils.ts @@ -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, + 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 +} From 0c397f654d89692a329c757a7525164e895bc279 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 25 Mar 2026 16:41:54 +0700 Subject: [PATCH 5/5] Fix github UI issues (#10684) * Fix github UI issues Signed-off-by: Artem Savchenko * Fix formatting Signed-off-by: Artem Savchenko * Remove legacy project fields Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- services/github/github-resources/package.json | 4 +++- .../src/components/ConnectProject.svelte | 15 +++------------ .../src/components/PullRequestDiff.svelte | 18 +++++++++--------- .../components/RepositoryPresenterRef.svelte | 2 +- .../RepositoryPresenterRefEditor.svelte | 2 +- .../GithubReviewThreadPresenter.svelte | 11 +++++------ 6 files changed, 22 insertions(+), 30 deletions(-) diff --git a/services/github/github-resources/package.json b/services/github/github-resources/package.json index ee87cda034..2400d26a6e 100644 --- a/services/github/github-resources/package.json +++ b/services/github/github-resources/package.json @@ -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", diff --git a/services/github/github-resources/src/components/ConnectProject.svelte b/services/github/github-resources/src/components/ConnectProject.svelte index 8cd1ffe888..c76c633e0a 100644 --- a/services/github/github-resources/src/components/ConnectProject.svelte +++ b/services/github/github-resources/src/components/ConnectProject.svelte @@ -1,7 +1,7 @@
{ - expanded = value - } + onExpand: onDiffExpand }} /> {/if}