Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions services/github/pod-github/src/__tests__/workspaceUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
56 changes: 23 additions & 33 deletions services/github/pod-github/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import core, {
Client,
ClientConnectEvent,
DocumentUpdate,
isActiveMode,
isDeletingMode,
MeasureContext,
PersonId,
RateLimiter,
Expand Down Expand Up @@ -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'
Expand All @@ -68,6 +67,8 @@ interface IntegrationDataValue {
installationId: number | number[]
}

export { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'

export class PlatformWorker {
private readonly clients = new Map<WorkspaceUuid, GithubWorker>()

Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions services/github/pod-github/src/workspaceUtils.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading