WorkspaceController.java
package com.taxonomy.workspace.controller;
import com.taxonomy.dto.ContextComparison;
import com.taxonomy.dto.ContextMode;
import com.taxonomy.dto.ContextRef;
import com.taxonomy.dto.SemanticChange;
import com.taxonomy.dto.WorkspaceInfo;
import com.taxonomy.versioning.model.ContextHistoryRecord;
import com.taxonomy.versioning.service.ContextCompareService;
import com.taxonomy.versioning.service.ContextHistoryService;
import com.taxonomy.versioning.service.ContextNavigationService;
import com.taxonomy.workspace.service.SyncIntegrationService;
import com.taxonomy.workspace.service.SystemRepositoryService;
import com.taxonomy.workspace.service.WorkspaceManager;
import com.taxonomy.workspace.service.WorkspaceProjectionService;
import com.taxonomy.workspace.service.WorkspaceResolver;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.model.UserWorkspace;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* REST API for workspace management.
*
* <p>Provides endpoints to query the current user's workspace, list all active
* workspaces (admin), and manage workspace lifecycle. Each authenticated user
* automatically gets a personal workspace on first access.
*
* <p>Additional endpoints support context comparison, synchronization with the
* shared integration repository, navigation history, and projection state.
*/
@RestController
@RequestMapping("/api/workspace")
@Tag(name = "Workspace Management")
public class WorkspaceController {
private final WorkspaceManager workspaceManager;
private final WorkspaceResolver workspaceResolver;
private final ContextCompareService contextCompareService;
private final ContextHistoryService contextHistoryService;
private final ContextNavigationService contextNavigationService;
private final SyncIntegrationService syncIntegrationService;
private final WorkspaceProjectionService workspaceProjectionService;
private final SystemRepositoryService systemRepositoryService;
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WorkspaceController.class);
public WorkspaceController(WorkspaceManager workspaceManager,
WorkspaceResolver workspaceResolver,
ContextCompareService contextCompareService,
ContextHistoryService contextHistoryService,
ContextNavigationService contextNavigationService,
SyncIntegrationService syncIntegrationService,
WorkspaceProjectionService workspaceProjectionService,
SystemRepositoryService systemRepositoryService) {
this.workspaceManager = workspaceManager;
this.workspaceResolver = workspaceResolver;
this.contextCompareService = contextCompareService;
this.contextHistoryService = contextHistoryService;
this.contextNavigationService = contextNavigationService;
this.syncIntegrationService = syncIntegrationService;
this.workspaceProjectionService = workspaceProjectionService;
this.systemRepositoryService = systemRepositoryService;
}
@GetMapping("/current")
@Operation(summary = "Get the current user's workspace",
description = "Returns workspace metadata including current branch, context, " +
"and timestamps for the authenticated user.")
public ResponseEntity<WorkspaceInfo> getCurrentWorkspace() {
String user = workspaceResolver.resolveCurrentUsername();
return ResponseEntity.ok(workspaceManager.getWorkspaceInfo(user));
}
@GetMapping("/active")
@Operation(summary = "List all active workspaces",
description = "Returns all currently active (in-memory) workspaces. " +
"Useful for admin monitoring of concurrent users.")
public ResponseEntity<List<WorkspaceInfo>> listActiveWorkspaces() {
return ResponseEntity.ok(workspaceManager.listActiveWorkspaces());
}
@GetMapping("/stats")
@Operation(summary = "Workspace statistics",
description = "Returns basic statistics about active workspaces.")
public ResponseEntity<Map<String, Object>> getStats() {
return ResponseEntity.ok(Map.of(
"activeWorkspaces", workspaceManager.getActiveWorkspaceCount()
));
}
@PostMapping("/evict")
@Operation(summary = "Evict a user's workspace from memory",
description = "Removes the in-memory workspace state for the specified user. " +
"The workspace will be recreated on next access. " +
"Admin-only operation for maintenance.")
public ResponseEntity<Map<String, Object>> evictWorkspace(
@RequestParam String username) {
workspaceManager.evictWorkspace(username);
return ResponseEntity.ok(Map.of(
"evicted", username,
"success", true
));
}
// ── Multi-Workspace Management ────────────────────────────────
@GetMapping("/list")
@Operation(summary = "List all workspaces for the current user",
description = "Returns all non-archived workspaces for the authenticated user.")
public ResponseEntity<List<Map<String, Object>>> listWorkspaces() {
String user = workspaceResolver.resolveCurrentUsername();
List<UserWorkspace> workspaces = workspaceManager.listUserWorkspaces(user);
List<Map<String, Object>> result = workspaces.stream()
.map(this::workspaceToMap)
.toList();
return ResponseEntity.ok(result);
}
@PostMapping("/create")
@Operation(summary = "Create a new workspace",
description = "Creates a new workspace for the authenticated user.")
public ResponseEntity<Map<String, Object>> createWorkspace(@RequestBody Map<String, String> body) {
String user = workspaceResolver.resolveCurrentUsername();
String displayName = body.get("displayName");
String description = body.get("description");
if (displayName == null || displayName.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "displayName is required"
));
}
try {
UserWorkspace ws = workspaceManager.createWorkspace(user, displayName, description);
return ResponseEntity.ok(workspaceToMap(ws));
} catch (Exception e) {
log.warn("Failed to create workspace for user '{}'", user, e);
return ResponseEntity.internalServerError().body(Map.of(
"error", "Failed to create workspace",
"message", e.getMessage()
));
}
}
@PutMapping("/{id}/rename")
@Operation(summary = "Rename a workspace",
description = "Changes the display name of a workspace.")
public ResponseEntity<Map<String, Object>> renameWorkspace(
@PathVariable String id, @RequestBody Map<String, String> body) {
String user = workspaceResolver.resolveCurrentUsername();
String displayName = body.get("displayName");
if (displayName == null || displayName.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "displayName is required"
));
}
try {
UserWorkspace ws = workspaceManager.renameWorkspace(user, id, displayName);
return ResponseEntity.ok(workspaceToMap(ws));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", e.getMessage()
));
}
}
@PutMapping("/{id}/description")
@Operation(summary = "Update workspace description",
description = "Updates the description of a workspace.")
public ResponseEntity<Map<String, Object>> updateDescription(
@PathVariable String id, @RequestBody Map<String, String> body) {
String user = workspaceResolver.resolveCurrentUsername();
String description = body.get("description");
try {
UserWorkspace ws = workspaceManager.updateDescription(user, id, description);
return ResponseEntity.ok(workspaceToMap(ws));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", e.getMessage()
));
}
}
@PostMapping("/{id}/switch")
@Operation(summary = "Switch active workspace",
description = "Switches the current user's active workspace.")
public ResponseEntity<Map<String, Object>> switchWorkspace(@PathVariable String id) {
String user = workspaceResolver.resolveCurrentUsername();
try {
UserWorkspace ws = workspaceManager.switchWorkspace(user, id);
return ResponseEntity.ok(workspaceToMap(ws));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", e.getMessage()
));
}
}
@PostMapping("/{id}/archive")
@Operation(summary = "Archive a workspace",
description = "Soft-deletes a workspace by marking it as archived.")
public ResponseEntity<Map<String, Object>> archiveWorkspace(@PathVariable String id) {
String user = workspaceResolver.resolveCurrentUsername();
try {
UserWorkspace ws = workspaceManager.archiveWorkspace(id, user);
return ResponseEntity.ok(workspaceToMap(ws));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", e.getMessage()
));
}
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a workspace",
description = "Permanently deletes a workspace. Only the owner can delete " +
"their own non-shared, non-default workspaces.")
public ResponseEntity<Map<String, Object>> deleteWorkspace(@PathVariable String id) {
String user = workspaceResolver.resolveCurrentUsername();
try {
workspaceManager.deleteWorkspace(id, user);
return ResponseEntity.ok(Map.of(
"deleted", id,
"success", true
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", e.getMessage()
));
}
}
@GetMapping("/{id}/info")
@Operation(summary = "Get workspace info by ID",
description = "Returns workspace metadata for the specified workspace.")
public ResponseEntity<Map<String, Object>> getWorkspaceInfo(@PathVariable String id) {
UserWorkspace ws = workspaceManager.getWorkspaceById(id);
if (ws == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(workspaceToMap(ws));
}
// ── Compare ─────────────────────────────────────────────────────
@PostMapping("/compare")
@Operation(summary = "Compare two branches or contexts",
description = "Performs a semantic diff between two branches or specific commits. " +
"Returns a summary of changes, individual semantic changes, and an " +
"optional raw DSL diff.")
public ResponseEntity<?> compare(
@RequestParam String leftBranch,
@RequestParam String rightBranch,
@RequestParam(required = false) String leftCommit,
@RequestParam(required = false) String rightCommit,
@RequestParam(required = false) Set<String> filter) {
try {
ContextRef left = readOnlyContextRef(leftBranch, leftCommit);
ContextRef right = readOnlyContextRef(rightBranch, rightCommit);
ContextComparison comparison;
if (leftCommit != null || rightCommit != null) {
comparison = contextCompareService.compareContexts(left, right);
} else {
comparison = contextCompareService.compareBranches(left, right);
}
return ResponseEntity.ok(applyFilter(comparison, filter));
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Compare failed",
"message", e.getMessage()
));
}
}
// ── Sync operations ─────────────────────────────────────────────
@PostMapping("/sync-from-shared")
@Operation(summary = "Sync from shared repository",
description = "Merges the shared integration branch into the user's branch, " +
"bringing it up to date with the latest team-wide state.")
public ResponseEntity<Map<String, Object>> syncFromShared(
@RequestParam(required = false) String userBranch) {
String user = workspaceResolver.resolveCurrentUsername();
String branch = resolveBranch(user, userBranch);
try {
String mergeCommit = syncIntegrationService.syncFromShared(user, branch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("branch", branch);
result.put("mergeCommit", mergeCommit);
result.put("syncedAt", Instant.now().toString());
return ResponseEntity.ok(result);
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Sync from shared failed",
"message", e.getMessage()
));
}
}
@PostMapping("/publish")
@Operation(summary = "Publish to shared repository",
description = "Merges the user's branch into the shared integration branch, " +
"making local changes available to all team members.")
public ResponseEntity<Map<String, Object>> publish(
@RequestParam(required = false) String userBranch) {
String user = workspaceResolver.resolveCurrentUsername();
String branch = resolveBranch(user, userBranch);
try {
String mergeCommit = syncIntegrationService.publishToShared(user, branch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("branch", branch);
result.put("mergeCommit", mergeCommit);
result.put("publishedAt", Instant.now().toString());
return ResponseEntity.ok(result);
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Publish failed",
"message", e.getMessage()
));
}
}
@GetMapping("/sync-state")
@Operation(summary = "Get current sync state",
description = "Returns the synchronization status between the user's workspace " +
"and the shared repository, including last sync time and " +
"unpublished commit count.")
public ResponseEntity<Map<String, Object>> getSyncState() {
String user = workspaceResolver.resolveCurrentUsername();
var state = syncIntegrationService.getSyncState(user);
Map<String, Object> result = new LinkedHashMap<>();
result.put("username", user);
result.put("syncStatus", state.getSyncStatus());
result.put("lastSyncedCommitId", state.getLastSyncedCommitId());
result.put("lastSyncTimestamp", state.getLastSyncTimestamp());
result.put("lastPublishedCommitId", state.getLastPublishedCommitId());
result.put("lastPublishTimestamp", state.getLastPublishTimestamp());
result.put("unpublishedCommitCount", state.getUnpublishedCommitCount());
return ResponseEntity.ok(result);
}
// ── History ─────────────────────────────────────────────────────
@GetMapping("/history")
@Operation(summary = "Get navigation history",
description = "Returns the user's recent context navigation history, " +
"ordered newest first (max 50 entries).")
public ResponseEntity<List<ContextHistoryRecord>> getHistory() {
String user = workspaceResolver.resolveCurrentUsername();
return ResponseEntity.ok(contextHistoryService.getHistory(user));
}
// ── Local changes ───────────────────────────────────────────────
@GetMapping("/local-changes")
@Operation(summary = "Get count of local unpublished changes",
description = "Returns the number of commits on the user's branch that " +
"have not been published to the shared repository.")
public ResponseEntity<Map<String, Object>> getLocalChanges(
@RequestParam(required = false) String branch) {
String user = workspaceResolver.resolveCurrentUsername();
String resolvedBranch = resolveBranch(user, branch);
try {
int changeCount = syncIntegrationService.getLocalChanges(user, resolvedBranch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("branch", resolvedBranch);
result.put("changeCount", changeCount);
result.put("hasUnpublishedChanges", changeCount > 0);
return ResponseEntity.ok(result);
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Could not count local changes",
"message", e.getMessage()
));
}
}
// ── Dirty check ─────────────────────────────────────────────────
@GetMapping("/dirty")
@Operation(summary = "Check if workspace has unsaved changes",
description = "Returns whether the user's workspace has unpublished " +
"changes relative to the shared repository.")
public ResponseEntity<Map<String, Object>> isDirty() {
String user = workspaceResolver.resolveCurrentUsername();
boolean dirty = syncIntegrationService.isDirty(user);
Map<String, Object> result = new LinkedHashMap<>();
result.put("username", user);
result.put("dirty", dirty);
result.put("syncStatus", syncIntegrationService.getSyncState(user).getSyncStatus());
return ResponseEntity.ok(result);
}
// ── Diverged resolution ─────────────────────────────────────────
@PostMapping("/resolve-diverged")
@Operation(summary = "Resolve a diverged sync state",
description = "Resolves a diverged state between user and shared branches. " +
"Strategies: MERGE (attempt merge), KEEP_MINE (publish local), " +
"TAKE_SHARED (overwrite local with shared).")
public ResponseEntity<Map<String, Object>> resolveDiverged(
@RequestParam String strategy,
@RequestParam(required = false) String userBranch) {
String user = workspaceResolver.resolveCurrentUsername();
String branch = resolveBranch(user, userBranch);
try {
SyncIntegrationService.DivergedStrategy parsedStrategy =
SyncIntegrationService.DivergedStrategy.valueOf(strategy.toUpperCase());
String message = syncIntegrationService.resolveDiverged(user, branch, parsedStrategy);
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("strategy", parsedStrategy.name());
result.put("branch", branch);
result.put("message", message);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid strategy: " + strategy,
"validStrategies", List.of("MERGE", "KEEP_MINE", "TAKE_SHARED")
));
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Diverged resolution failed",
"message", e.getMessage()
));
}
}
// ── Projection ──────────────────────────────────────────────────
@GetMapping("/projection")
@Operation(summary = "Get projection state",
description = "Returns the materialization and index state for the " +
"user's workspace, including both in-memory and persisted data.")
public ResponseEntity<Map<String, Object>> getProjection() {
String user = workspaceResolver.resolveCurrentUsername();
return ResponseEntity.ok(workspaceProjectionService.getProjectionInfo(user));
}
// ── Provisioning & Topology ────────────────────────────────────
@GetMapping("/provisioning-status")
@Operation(summary = "Get workspace provisioning status",
description = "Returns the current provisioning state of the user's workspace.")
public ResponseEntity<Map<String, Object>> getProvisioningStatus() {
String user = workspaceResolver.resolveCurrentUsername();
UserWorkspace ws = workspaceManager.findUserWorkspace(user);
Map<String, Object> result = new LinkedHashMap<>();
if (ws == null) {
result.put("status", "NOT_PROVISIONED");
} else {
result.put("status", ws.getProvisioningStatus().name());
result.put("topologyMode", ws.getTopologyMode().name());
result.put("sourceRepository", ws.getSourceRepositoryId());
result.put("error", ws.getProvisioningError());
}
return ResponseEntity.ok(result);
}
@PostMapping("/provision")
@Operation(summary = "Provision workspace repository",
description = "Creates the user's personal branch from the shared repository.")
public ResponseEntity<Map<String, Object>> provisionWorkspace() {
String user = workspaceResolver.resolveCurrentUsername();
try {
UserWorkspace ws = workspaceManager.provisionWorkspaceRepository(user);
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", ws.getProvisioningStatus().name());
result.put("branch", ws.getCurrentBranch());
result.put("baseBranch", ws.getBaseBranch());
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of(
"error", "Provisioning failed",
"message", e.getMessage()
));
}
}
@GetMapping("/topology")
@Operation(summary = "Get repository topology",
description = "Returns the repository topology mode and shared source information.")
public ResponseEntity<Map<String, Object>> getTopology() {
SystemRepository sysRepo = systemRepositoryService.getPrimaryRepository();
Map<String, Object> result = new LinkedHashMap<>();
result.put("mode", sysRepo.getTopologyMode().name());
result.put("sharedBranch", sysRepo.getDefaultBranch());
result.put("systemRepositoryId", sysRepo.getRepositoryId());
result.put("displayName", sysRepo.getDisplayName());
return ResponseEntity.ok(result);
}
// ── Internal helpers ────────────────────────────────────────────
/**
* Resolve the branch name, falling back to the current workspace branch
* or "draft" as default.
*/
private String resolveBranch(String username, String explicitBranch) {
if (explicitBranch != null && !explicitBranch.isBlank()) {
return explicitBranch;
}
WorkspaceInfo info = workspaceManager.getWorkspaceInfo(username);
return info != null && info.currentBranch() != null
? info.currentBranch()
: "draft";
}
/**
* Create a read-only {@link ContextRef} for comparison operations.
*/
private ContextRef readOnlyContextRef(String branch, String commitId) {
return new ContextRef(
UUID.randomUUID().toString(), branch, commitId,
Instant.now(), ContextMode.READ_ONLY,
null, null, null, null, null, false);
}
/**
* Convert a {@link UserWorkspace} entity to a response map.
*/
private Map<String, Object> workspaceToMap(UserWorkspace ws) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("workspaceId", ws.getWorkspaceId());
map.put("username", ws.getUsername());
map.put("displayName", ws.getDisplayName());
map.put("description", ws.getDescription());
map.put("currentBranch", ws.getCurrentBranch());
map.put("baseBranch", ws.getBaseBranch());
map.put("shared", ws.isShared());
map.put("archived", ws.isArchived());
map.put("isDefault", ws.isDefault());
map.put("provisioningStatus", ws.getProvisioningStatus().name());
map.put("topologyMode", ws.getTopologyMode().name());
map.put("createdAt", ws.getCreatedAt() != null ? ws.getCreatedAt().toString() : null);
map.put("lastAccessedAt", ws.getLastAccessedAt() != null ? ws.getLastAccessedAt().toString() : null);
return map;
}
private ContextComparison applyFilter(ContextComparison comparison, Set<String> filter) {
if (filter == null || filter.isEmpty()) {
return comparison;
}
List<SemanticChange> filtered = comparison.changes().stream()
.filter(c -> {
if (filter.contains("elements") && "ELEMENT".equals(c.category())) return true;
if (filter.contains("relations") && "RELATION".equals(c.category())) return true;
return false;
})
.toList();
return new ContextComparison(comparison.left(), comparison.right(),
comparison.summary(), filtered, comparison.rawDslDiff());
}
@GetMapping("/history/origin-stack")
@Operation(summary = "Get origin stack from current context",
description = "Returns the chain of origin contexts from the current context back to the root.")
public ResponseEntity<List<ContextHistoryRecord>> getOriginStack() {
String user = workspaceResolver.resolveCurrentUsername();
List<ContextHistoryRecord> history = contextHistoryService.getHistory(user);
// Filter to only entries that form the origin chain
// Walk backwards from most recent, following originContextId links
List<ContextHistoryRecord> originStack = new ArrayList<>();
if (!history.isEmpty()) {
// Start with the most recent entry
ContextHistoryRecord current = history.get(0);
originStack.add(current);
// Follow origin chain
for (ContextHistoryRecord record : history) {
if (current.getOriginContextId() != null
&& current.getOriginContextId().equals(record.getToContextId())) {
originStack.add(record);
current = record;
}
}
}
return ResponseEntity.ok(originStack);
}
@PostMapping("/history/return-to")
@Operation(summary = "Return to a specific history entry",
description = "Navigates to a specific context from the navigation history, " +
"updating the user's current context and recording the navigation event.")
public ResponseEntity<Map<String, Object>> returnToHistoryEntry(
@RequestParam String contextId) {
String user = workspaceResolver.resolveCurrentUsername();
List<ContextHistoryRecord> history = contextHistoryService.getHistory(user);
// Find the target entry
ContextHistoryRecord target = history.stream()
.filter(r -> contextId.equals(r.getToContextId()))
.findFirst()
.orElse(null);
Map<String, Object> result = new LinkedHashMap<>();
if (target != null) {
// Perform actual navigation via the context navigation service
ContextRef newContext = contextNavigationService.switchContext(
user, target.getToBranch(), target.getToCommitId());
result.put("success", true);
result.put("branch", newContext.branch());
result.put("commitId", newContext.commitId());
result.put("contextId", newContext.contextId());
} else {
result.put("success", false);
result.put("error", "History entry not found for contextId: " + contextId);
}
return ResponseEntity.ok(result);
}
@DeleteMapping("/history")
@Operation(summary = "Clear navigation history",
description = "Deletes all navigation history entries for the current user.")
public ResponseEntity<Map<String, Object>> clearHistory() {
String user = workspaceResolver.resolveCurrentUsername();
contextHistoryService.clearHistory(user);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "History cleared for user: " + user
));
}
}