feat(sync): add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket

This commit is contained in:
2026-02-28 16:33:53 +00:00
parent 2f050744bc
commit f7e16aa350
30 changed files with 2983 additions and 21 deletions

View File

@@ -77,6 +77,15 @@ export class ConnectionManager {
for (const key of keys) {
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
if (conn) {
// Migrate legacy baseGroup/baseGroupId property names
if ((conn as any).baseGroup !== undefined && conn.groupFilter === undefined) {
conn.groupFilter = (conn as any).baseGroup;
delete (conn as any).baseGroup;
}
if ((conn as any).baseGroupId !== undefined && conn.groupFilterId === undefined) {
conn.groupFilterId = (conn as any).baseGroupId;
delete (conn as any).baseGroupId;
}
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
// Token is in keychain — retrieve it
const realToken = await this.smartSecret.getSecret(conn.id);
@@ -142,6 +151,7 @@ export class ConnectionManager {
providerType: interfaces.data.TProviderType,
baseUrl: string,
token: string,
groupFilter?: string,
): Promise<interfaces.data.IProviderConnection> {
const connection: interfaces.data.IProviderConnection = {
id: crypto.randomUUID(),
@@ -151,6 +161,7 @@ export class ConnectionManager {
token,
createdAt: Date.now(),
status: 'disconnected',
groupFilter: groupFilter || undefined,
};
this.connections.push(connection);
await this.persistConnection(connection);
@@ -160,13 +171,17 @@ export class ConnectionManager {
async updateConnection(
id: string,
updates: { name?: string; baseUrl?: string; token?: string },
updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: string },
): Promise<interfaces.data.IProviderConnection> {
const conn = this.connections.find((c) => c.id === id);
if (!conn) throw new Error(`Connection not found: ${id}`);
if (updates.name) conn.name = updates.name;
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
if (updates.token) conn.token = updates.token;
if (updates.groupFilter !== undefined) {
conn.groupFilter = updates.groupFilter || undefined;
conn.groupFilterId = undefined; // Will be re-resolved on next test
}
await this.persistConnection(conn);
return { ...conn, token: '***' };
}
@@ -196,10 +211,39 @@ export class ConnectionManager {
const provider = this.getProvider(id);
const result = await provider.testConnection();
conn.status = result.ok ? 'connected' : 'error';
// Resolve group filter ID if connection has a groupFilter
if (result.ok && conn.groupFilter) {
await this.resolveGroupFilterId(conn);
}
await this.persistConnection(conn);
return result;
}
/**
* Resolves a human-readable groupFilter to the provider-specific group ID.
*/
private async resolveGroupFilterId(conn: interfaces.data.IProviderConnection): Promise<void> {
if (!conn.groupFilter) {
conn.groupFilterId = undefined;
return;
}
try {
if (conn.providerType === 'gitlab') {
const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
const group = await gitlabClient.getGroupByPath(conn.groupFilter);
conn.groupFilterId = String(group.id);
logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`);
} else {
// For Gitea, the org name IS the ID
conn.groupFilterId = conn.groupFilter;
logger.info(`Group filter for Gitea connection set to org "${conn.groupFilterId}"`);
}
} catch (err) {
logger.warn(`Failed to resolve group filter "${conn.groupFilter}": ${err}`);
conn.groupFilterId = undefined;
}
}
/**
* Factory: returns the correct provider instance for a connection ID
*/
@@ -209,9 +253,9 @@ export class ConnectionManager {
switch (conn.providerType) {
case 'gitea':
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
case 'gitlab':
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId);
default:
throw new Error(`Unknown provider type: ${conn.providerType}`);
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { ConnectionManager } from './connectionmanager.ts';
import { ActionLog } from './actionlog.ts';
import { SyncManager } from './syncmanager.ts';
import { OpsServer } from '../opsserver/index.ts';
import { StorageManager } from '../storage/index.ts';
import { CacheDb, CacheCleaner, CachedProject, CachedSecret, SecretsScanService } from '../cache/index.ts';
@@ -18,11 +19,14 @@ export class GitopsApp {
public opsServer: OpsServer;
public cacheDb: CacheDb;
public cacheCleaner: CacheCleaner;
public syncManager!: SyncManager;
public secretsScanService!: SecretsScanService;
private scanIntervalId: number | null = null;
private paths: ReturnType<typeof resolvePaths>;
constructor() {
const paths = resolvePaths();
this.paths = paths;
this.storageManager = new StorageManager({
backend: 'filesystem',
fsPath: paths.defaultStoragePath,
@@ -51,6 +55,15 @@ export class GitopsApp {
// Initialize connection manager (loads saved connections)
await this.connectionManager.init();
// Initialize sync manager
this.syncManager = new SyncManager(
this.storageManager,
this.connectionManager,
this.actionLog,
this.paths.syncMirrorsPath,
);
await this.syncManager.init();
// Initialize secrets scan service with 24h auto-scan
this.secretsScanService = new SecretsScanService(this.connectionManager);
const SCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -80,6 +93,7 @@ export class GitopsApp {
clearInterval(this.scanIntervalId);
this.scanIntervalId = null;
}
await this.syncManager.stop();
await this.opsServer.stop();
this.cacheCleaner.stop();
await this.cacheDb.stop();

1600
ts/classes/syncmanager.ts Normal file

File diff suppressed because it is too large Load Diff