WorkspaceManager.java
package com.taxonomy.workspace.service;
import com.taxonomy.dto.ContextRef;
import com.taxonomy.dto.WorkspaceInfo;
import com.taxonomy.dto.WorkspaceRole;
import com.taxonomy.workspace.model.RepositoryTopologyMode;
import com.taxonomy.workspace.model.UserWorkspace;
import com.taxonomy.workspace.model.WorkspaceProvisioningStatus;
import com.taxonomy.workspace.repository.UserWorkspaceRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
/**
* Manages per-user workspace state for multi-user architecture editing.
*
* <p>Each authenticated user gets their own {@link UserWorkspaceState} with
* independent context navigation, projection tracking, and operation state.
* When a {@link DslGitRepositoryFactory} is available, each workspace gets its
* own logically separate Git repository (same database, different namespace).
* Otherwise, workspaces are isolated via branches in a shared repository.
*
* <p>The manager maintains an in-memory map of active workspace states (keyed by
* workspaceId) and persists workspace metadata (branch, timestamps) via
* {@link UserWorkspaceRepository}. A secondary map tracks the currently active
* workspace per user.
*
* <p>A special "shared" workspace exists for the integration repository concept —
* the canonical team-wide state that users synchronize with.
*/
@Service
public class WorkspaceManager {
private static final Logger log = LoggerFactory.getLogger(WorkspaceManager.class);
/** Default username used when no authentication context is available (e.g. tests). */
public static final String DEFAULT_USER = "anonymous";
private final UserWorkspaceRepository workspaceRepository;
private final int maxHistory;
private final SystemRepositoryService systemRepositoryService;
private final DslGitRepository gitRepository;
private final DslGitRepositoryFactory repositoryFactory;
/** Active workspace states keyed by workspaceId. */
private final ConcurrentMap<String, UserWorkspaceState> activeWorkspaces = new ConcurrentHashMap<>();
/** Maps username to the currently active workspaceId for that user. */
private final ConcurrentMap<String, String> activeWorkspaceByUser = new ConcurrentHashMap<>();
@Autowired
public WorkspaceManager(UserWorkspaceRepository workspaceRepository,
@Value("${taxonomy.context.max-history:50}") int maxHistory,
SystemRepositoryService systemRepositoryService,
DslGitRepositoryFactory repositoryFactory) {
this.workspaceRepository = workspaceRepository;
this.maxHistory = maxHistory;
this.systemRepositoryService = systemRepositoryService;
this.gitRepository = repositoryFactory.getSystemRepository();
this.repositoryFactory = repositoryFactory;
}
/**
* Legacy constructor for backward compatibility (used in tests).
*/
public WorkspaceManager(UserWorkspaceRepository workspaceRepository,
int maxHistory,
SystemRepositoryService systemRepositoryService,
DslGitRepository gitRepository) {
this.workspaceRepository = workspaceRepository;
this.maxHistory = maxHistory;
this.systemRepositoryService = systemRepositoryService;
this.gitRepository = gitRepository;
this.repositoryFactory = null;
}
/**
* Get or create the workspace state for a user's default workspace.
*
* <p>If no in-memory state exists, a new one is created and a persistent
* workspace record is ensured in the database.
*
* @param username the authenticated user's username
* @return the user's workspace state (never null)
*/
public UserWorkspaceState getOrCreateWorkspace(String username) {
if (username == null || username.isBlank()) {
username = DEFAULT_USER;
}
String user = username;
// Check if the user already has an active workspace
String activeWsId = activeWorkspaceByUser.get(user);
if (activeWsId != null) {
UserWorkspaceState existing = activeWorkspaces.get(activeWsId);
if (existing != null) {
return existing;
}
}
// Ensure a persistent workspace exists and activate it
ensurePersistentWorkspace(user);
// Find the default (or first) non-archived workspace for this user
UserWorkspace ws = safeOptional(workspaceRepository.findByUsernameAndIsDefaultTrue(user));
if (ws != null && ws.isArchived()) {
ws = null;
}
if (ws == null) {
ws = safeOptional(workspaceRepository.findByUsernameAndSharedFalse(user));
if (ws != null && ws.isArchived()) {
ws = null;
}
}
String workspaceId;
if (ws != null) {
workspaceId = ws.getWorkspaceId();
} else {
workspaceId = user + "-workspace";
}
String wsId = workspaceId;
UserWorkspaceState state = activeWorkspaces.computeIfAbsent(wsId, id -> {
log.info("Creating workspace state for user '{}', workspaceId='{}'", user, id);
return new UserWorkspaceState(user, maxHistory);
});
activeWorkspaceByUser.put(user, wsId);
return state;
}
/**
* Get or create the workspace state for a specific workspace.
*
* @param username the authenticated user's username
* @param workspaceId the workspace ID to load
* @return the workspace state (never null)
*/
public UserWorkspaceState getOrCreateWorkspace(String username, String workspaceId) {
if (username == null || username.isBlank()) {
username = DEFAULT_USER;
}
String user = username;
UserWorkspaceState state = activeWorkspaces.computeIfAbsent(workspaceId, id -> {
log.info("Creating workspace state for user '{}', workspaceId='{}'", user, id);
return new UserWorkspaceState(user, maxHistory);
});
activeWorkspaceByUser.put(user, workspaceId);
return state;
}
/**
* Get the workspace state for a user, or null if not active.
*
* @param username the user's username
* @return the workspace state, or null if the user has no active workspace
*/
public UserWorkspaceState getWorkspace(String username) {
String user = username != null ? username : DEFAULT_USER;
String activeWsId = activeWorkspaceByUser.get(user);
if (activeWsId != null) {
return activeWorkspaces.get(activeWsId);
}
return null;
}
/**
* List all active workspaces as {@link WorkspaceInfo} DTOs.
*
* @return list of active workspace summaries
*/
public List<WorkspaceInfo> listActiveWorkspaces() {
return activeWorkspaces.values().stream()
.map(this::toWorkspaceInfo)
.toList();
}
/**
* Get workspace info for a specific user.
*
* @param username the user's username
* @return workspace info, or null if not found
*/
public WorkspaceInfo getWorkspaceInfo(String username) {
UserWorkspaceState state = getOrCreateWorkspace(username);
return toWorkspaceInfo(state);
}
/**
* Remove a user's workspace state from the in-memory cache.
*
* <p>This does not delete the persistent workspace record; the user's
* workspace will be recreated on next access. Useful for cleanup on logout.
*
* @param username the user's username
*/
public void evictWorkspace(String username) {
String activeWsId = activeWorkspaceByUser.remove(username);
if (activeWsId != null) {
UserWorkspaceState removed = activeWorkspaces.remove(activeWsId);
if (removed != null) {
log.info("Evicted workspace state for user '{}', workspaceId='{}'", username, activeWsId);
}
}
}
/**
* Get the number of active workspaces.
*
* @return count of active user workspaces
*/
public int getActiveWorkspaceCount() {
return activeWorkspaces.size();
}
/**
* Find the persistent workspace entity for a user (active workspace first).
*
* @param username the user's username
* @return the workspace entity, or null if not found
*/
public UserWorkspace findUserWorkspace(String username) {
try {
// Try active workspace first
UserWorkspace active = findActiveWorkspace(username);
if (active != null) {
return active;
}
return workspaceRepository.findByUsernameAndSharedFalse(username).orElse(null);
} catch (Exception e) {
log.debug("Could not find workspace for '{}': {}", username, e.getMessage());
return null;
}
}
// ── Multi-Workspace Management ──────────────────────────────────
/**
* Create a new workspace for the given user.
*
* @param username the owner
* @param displayName the human-readable name
* @param description optional description
* @return the newly created workspace entity
*/
public UserWorkspace createWorkspace(String username, String displayName, String description) {
UserWorkspace ws = new UserWorkspace();
ws.setWorkspaceId(UUID.randomUUID().toString());
ws.setUsername(username);
ws.setDisplayName(displayName);
ws.setDescription(description);
ws.setCurrentBranch("draft");
ws.setBaseBranch("draft");
ws.setShared(false);
ws.setArchived(false);
ws.setDefault(false);
ws.setProvisioningStatus(WorkspaceProvisioningStatus.NOT_PROVISIONED);
ws.setTopologyMode(RepositoryTopologyMode.INTERNAL_SHARED);
ws.setCreatedAt(Instant.now());
ws.setLastAccessedAt(Instant.now());
workspaceRepository.save(ws);
log.info("Created new workspace '{}' for user '{}'", displayName, username);
return ws;
}
/**
* Switch the user's active workspace to the given workspace.
*
* @param username the user
* @param workspaceId the workspace to switch to
* @return the workspace entity that was switched to
*/
public UserWorkspace switchWorkspace(String username, String workspaceId) {
UserWorkspace ws = workspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found: " + workspaceId));
if (!ws.getUsername().equals(username)) {
throw new IllegalArgumentException("Cannot switch to workspace owned by another user");
}
if (ws.isArchived()) {
throw new IllegalArgumentException("Cannot switch to an archived workspace");
}
if (ws.isShared()) {
throw new IllegalArgumentException("Cannot switch to the shared workspace");
}
// Evict old active workspace from memory
String oldWsId = activeWorkspaceByUser.get(username);
if (oldWsId != null) {
activeWorkspaces.remove(oldWsId);
}
// Activate new workspace
activeWorkspaceByUser.put(username, workspaceId);
activeWorkspaces.computeIfAbsent(workspaceId, id ->
new UserWorkspaceState(username, maxHistory));
ws.setLastAccessedAt(Instant.now());
workspaceRepository.save(ws);
log.info("User '{}' switched to workspace '{}'", username, workspaceId);
return ws;
}
/**
* Rename a workspace.
*
* @param username the user requesting the rename
* @param workspaceId the workspace to rename
* @param newName the new display name
* @return the updated workspace entity
*/
public UserWorkspace renameWorkspace(String username, String workspaceId, String newName) {
UserWorkspace ws = workspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found: " + workspaceId));
if (!ws.getUsername().equals(username)) {
throw new IllegalArgumentException("User '" + username + "' does not own workspace: " + workspaceId);
}
if (ws.isArchived()) {
throw new IllegalStateException("Archived workspace cannot be renamed: " + workspaceId);
}
if (ws.isShared()) {
throw new IllegalStateException("Shared workspace cannot be renamed: " + workspaceId);
}
ws.setDisplayName(newName);
workspaceRepository.save(ws);
log.info("User '{}' renamed workspace '{}' to '{}'", username, workspaceId, newName);
return ws;
}
/**
* Archive a workspace (soft-delete).
*
* @param username the requesting user (must be the owner)
* @param workspaceId the workspace to archive
* @return the archived workspace entity
*/
public UserWorkspace archiveWorkspace(String workspaceId, String username) {
UserWorkspace ws = workspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found: " + workspaceId));
if (!ws.getUsername().equals(username)) {
throw new IllegalArgumentException("Cannot archive workspace owned by another user");
}
if (ws.isShared()) {
throw new IllegalArgumentException("Cannot archive the shared workspace");
}
if (ws.isDefault()) {
throw new IllegalArgumentException("Cannot archive the default workspace");
}
ws.setArchived(true);
workspaceRepository.save(ws);
// Remove from active state if loaded
activeWorkspaces.remove(workspaceId);
activeWorkspaceByUser.entrySet().removeIf(entry -> entry.getValue().equals(workspaceId));
log.info("Archived workspace '{}' for user '{}'", workspaceId, username);
return ws;
}
/**
* Hard-delete a workspace (only own, non-shared).
*
* @param workspaceId the workspace to delete
* @param username the requesting user (must be the owner)
*/
public void deleteWorkspace(String workspaceId, String username) {
UserWorkspace ws = workspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found: " + workspaceId));
if (!ws.getUsername().equals(username)) {
throw new IllegalArgumentException("Cannot delete workspace owned by another user");
}
if (ws.isShared()) {
throw new IllegalArgumentException("Cannot delete the shared workspace");
}
if (ws.isDefault()) {
throw new IllegalArgumentException("Cannot delete the default workspace");
}
// Remove from active state
activeWorkspaces.remove(workspaceId);
activeWorkspaceByUser.entrySet().removeIf(entry -> entry.getValue().equals(workspaceId));
workspaceRepository.delete(ws);
log.info("Deleted workspace '{}' for user '{}'", workspaceId, username);
}
/**
* List all non-archived workspaces for a user.
*
* @param username the user
* @return list of non-archived workspaces ordered by last accessed
*/
public List<UserWorkspace> listUserWorkspaces(String username) {
return workspaceRepository.findByUsernameAndArchivedFalseOrderByLastAccessedAtDesc(username);
}
/**
* Find the currently active workspace for a user from the in-memory map.
*
* @param username the user
* @return the active workspace entity, or null if none is active
*/
public UserWorkspace findActiveWorkspace(String username) {
String activeWsId = activeWorkspaceByUser.get(username);
if (activeWsId != null) {
try {
return workspaceRepository.findByWorkspaceId(activeWsId).orElse(null);
} catch (Exception e) {
log.debug("Could not find active workspace '{}' for '{}': {}",
activeWsId, username, e.getMessage());
}
}
return null;
}
/**
* Get a workspace by its ID.
*
* @param workspaceId the workspace ID
* @return the workspace entity, or null if not found
*/
public UserWorkspace getWorkspaceById(String workspaceId) {
try {
return workspaceRepository.findByWorkspaceId(workspaceId).orElse(null);
} catch (Exception e) {
log.debug("Could not find workspace '{}': {}", workspaceId, e.getMessage());
return null;
}
}
/**
* Update the description of a workspace.
*
* @param username the user requesting the update
* @param workspaceId the workspace to update
* @param description the new description
* @return the updated workspace entity
*/
public UserWorkspace updateDescription(String username, String workspaceId, String description) {
UserWorkspace ws = workspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException("Workspace not found: " + workspaceId));
if (!ws.getUsername().equals(username)) {
throw new IllegalArgumentException("User '" + username + "' does not own workspace: " + workspaceId);
}
if (ws.isArchived()) {
throw new IllegalStateException("Archived workspace cannot be updated: " + workspaceId);
}
ws.setDescription(description);
workspaceRepository.save(ws);
log.info("User '{}' updated description for workspace '{}'", username, workspaceId);
return ws;
}
// ── Provisioning ───────────────────────────────────────────────
/**
* Provision a user's workspace repository by creating a personal Git branch.
*
* <p>This method implements lazy provisioning: workspace metadata is created
* eagerly on first access, but the actual Git branch is only created when
* explicitly requested (e.g. when the user first needs write access).
*
* <p>If the workspace is already provisioned ({@code READY}), this method
* is a no-op and returns the existing workspace.
*
* @param username the authenticated user's username
* @return the provisioned workspace entity
* @throws RuntimeException if provisioning fails
*/
public UserWorkspace provisionWorkspaceRepository(String username) {
// Try to find workspace by active workspaceId first, fall back to username lookup
UserWorkspace ws = null;
String activeWsId = activeWorkspaceByUser.get(username);
if (activeWsId != null) {
ws = workspaceRepository.findByWorkspaceId(activeWsId).orElse(null);
}
if (ws == null) {
ws = workspaceRepository.findByUsernameAndSharedFalse(username)
.orElseThrow(() -> new IllegalStateException("No workspace metadata for " + username));
}
if (ws.getProvisioningStatus() == WorkspaceProvisioningStatus.READY) {
return ws;
}
ws.setProvisioningStatus(WorkspaceProvisioningStatus.PROVISIONING);
workspaceRepository.save(ws);
try {
var sysRepo = systemRepositoryService.getPrimaryRepository();
String baseBranch = sysRepo.getDefaultBranch();
if (repositoryFactory != null) {
// Per-workspace repository isolation: each workspace gets its own repo
DslGitRepository wsRepo = repositoryFactory.getWorkspaceRepository(ws.getWorkspaceId());
DslGitRepository sysGitRepo = repositoryFactory.getSystemRepository();
String sysDsl = sysGitRepo.getDslAtHead(baseBranch);
if (sysDsl != null) {
wsRepo.commitDsl("main", sysDsl, username, "Fork from shared/" + baseBranch);
}
ws.setProvisioningStatus(WorkspaceProvisioningStatus.READY);
ws.setSourceRepositoryId(sysRepo.getRepositoryId());
ws.setTopologyMode(sysRepo.getTopologyMode());
ws.setBaseBranch(baseBranch);
ws.setBaseCommit(sysGitRepo.getHeadCommit(baseBranch));
ws.setCurrentBranch("main");
ws.setCurrentCommit(wsRepo.getHeadCommit("main"));
ws.setSyncTargetBranch(baseBranch);
ws.setProvisionedAt(Instant.now());
ws.setProvisioningError(null);
workspaceRepository.save(ws);
log.info("Provisioned workspace for user '{}': repo='ws-{}', base='{}'",
username, ws.getWorkspaceId(), baseBranch);
} else {
// Legacy branch-based isolation: all workspaces share one repo
String userBranch = username + "/workspace/" + ws.getWorkspaceId();
String baseCommit = gitRepository.getHeadCommit(baseBranch);
if (baseCommit != null) {
gitRepository.createBranch(userBranch, baseBranch);
}
ws.setProvisioningStatus(WorkspaceProvisioningStatus.READY);
ws.setSourceRepositoryId(sysRepo.getRepositoryId());
ws.setTopologyMode(sysRepo.getTopologyMode());
ws.setBaseBranch(baseBranch);
ws.setBaseCommit(baseCommit);
ws.setCurrentBranch(userBranch);
ws.setCurrentCommit(baseCommit);
ws.setSyncTargetBranch(baseBranch);
ws.setProvisionedAt(Instant.now());
ws.setProvisioningError(null);
workspaceRepository.save(ws);
log.info("Provisioned workspace for user '{}': branch='{}', base='{}'",
username, userBranch, baseBranch);
}
return ws;
} catch (Exception e) {
ws.setProvisioningStatus(WorkspaceProvisioningStatus.FAILED);
ws.setProvisioningError(e.getMessage());
workspaceRepository.save(ws);
throw new RuntimeException("Could not provision workspace for " + username, e);
}
}
// ── Internal ────────────────────────────────────────────────────
private void ensurePersistentWorkspace(String username) {
try {
if (!workspaceRepository.existsByUsername(username)) {
UserWorkspace ws = new UserWorkspace();
ws.setWorkspaceId(UUID.randomUUID().toString());
ws.setUsername(username);
ws.setDisplayName(username + "'s workspace");
ws.setCurrentBranch("draft");
ws.setBaseBranch("draft");
ws.setShared(false);
ws.setDefault(true);
ws.setArchived(false);
ws.setProvisioningStatus(WorkspaceProvisioningStatus.NOT_PROVISIONED);
ws.setTopologyMode(RepositoryTopologyMode.INTERNAL_SHARED);
ws.setCreatedAt(Instant.now());
ws.setLastAccessedAt(Instant.now());
workspaceRepository.save(ws);
log.debug("Created persistent workspace for user '{}'", username);
} else {
workspaceRepository.findByUsernameAndSharedFalse(username)
.ifPresent(ws -> {
ws.setLastAccessedAt(Instant.now());
workspaceRepository.save(ws);
});
}
} catch (Exception e) {
// Non-fatal: workspace state works in-memory even if persistence fails
log.warn("Could not persist workspace for user '{}': {}", username, e.getMessage());
}
}
private WorkspaceInfo toWorkspaceInfo(UserWorkspaceState state) {
ContextRef ctx = state.getCurrentContext();
String provisioningStatus = "READY";
String topologyMode = "INTERNAL_SHARED";
String sourceRepositoryId = null;
String wsId = state.getUsername() + "-workspace";
String displayName = state.getUsername() + "'s workspace";
String description = null;
boolean archived = false;
boolean isDefault = false;
try {
UserWorkspace ws = findActiveWorkspace(state.getUsername());
if (ws == null) {
ws = workspaceRepository.findByUsernameAndSharedFalse(state.getUsername())
.orElse(null);
}
if (ws != null) {
provisioningStatus = ws.getProvisioningStatus().name();
topologyMode = ws.getTopologyMode().name();
sourceRepositoryId = ws.getSourceRepositoryId();
wsId = ws.getWorkspaceId();
displayName = ws.getDisplayName();
description = ws.getDescription();
archived = ws.isArchived();
isDefault = ws.isDefault();
}
} catch (Exception e) {
log.debug("Could not read provisioning info for '{}': {}", state.getUsername(), e.getMessage());
}
return new WorkspaceInfo(
wsId,
state.getUsername(),
displayName,
ctx != null ? ctx.branch() : "draft",
"draft",
false,
ctx,
Instant.now(),
Instant.now(),
provisioningStatus,
topologyMode,
sourceRepositoryId,
description,
archived,
isDefault
);
}
/**
* Safely unwrap an Optional that may itself be null (e.g. from an unstubbed mock).
*/
private static <T> T safeOptional(java.util.Optional<T> opt) {
return opt != null ? opt.orElse(null) : null;
}
}