package com.agent.management.engine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Pattern;
/**
* 工作流运行目录管理器
* 为每次工作流执行创建独立的工作目录,永久保留
*
*
目录结构:
*
* - 新建:{@code {basePath}/{workflowName}/{runId}/}(workflowName 为 kebab-case 唯一标识)
* - 兼容:旧目录 {@code {basePath}/{workflowId}/{runId}/} 仍可读取(用户改名前的历史 run)
*
*/
@Slf4j
@Component
public class WorkflowRunDirManager {
/** runId 白名单:UUID 截取的 8~16 位十六进制 */
private static final Pattern RUN_ID_PATTERN = Pattern.compile("^[a-fA-F0-9]{1,16}$");
private final Path basePath;
public WorkflowRunDirManager(
@Value("${workflow.run-dir:./data/workflow-runs}") String basePath) {
this.basePath = Paths.get(basePath).toAbsolutePath().normalize();
try {
Files.createDirectories(this.basePath);
log.info("[RunDirManager] 工作目录基路径: {}", this.basePath);
} catch (IOException e) {
throw new IllegalStateException("无法创建工作流运行目录: " + this.basePath, e);
}
}
/**
* 为指定工作流执行创建工作目录(新建使用 workflowName 作为目录段)。
*
* @param workflowId 工作流内部 ID(兼容旧目录兜底用)
* @param workflowName 工作流唯一标识(kebab-case),新建目录使用此字段
* @param runId 运行 ID(必须为 1~16 位十六进制)
*/
public Path createRunDir(Long workflowId, String workflowName, String runId) {
validateRunId(runId);
Path runDir = resolveByName(workflowName, runId);
try {
Files.createDirectories(runDir);
log.info("[RunDirManager] 创建运行目录: {}", runDir);
return runDir;
} catch (IOException e) {
throw new RuntimeException("创建运行目录失败: " + runDir, e);
}
}
/**
* 获取指定运行的工作目录(已存在时返回,不存在返回 null)。
* 查询顺序:先按 name,再按 id 兜底。
*/
public Path getRunDir(Long workflowId, String workflowName, String runId) {
try {
validateRunId(runId);
} catch (IllegalArgumentException e) {
return null;
}
// 1. 优先按 name 查找
if (workflowName != null && !workflowName.isEmpty()) {
Path byName = resolveByName(workflowName, runId);
if (Files.isDirectory(byName)) return byName;
}
// 2. 回退按 id 查找(兼容旧目录)
if (workflowId != null) {
Path byId = resolveById(workflowId, runId);
if (Files.isDirectory(byId)) return byId;
}
return null;
}
/**
* 兼容旧调用:仅按 id 创建/查询。新代码应使用带 name 的重载。
*/
@Deprecated
public Path createRunDir(Long workflowId, String runId) {
return createRunDir(workflowId, String.valueOf(workflowId), runId);
}
@Deprecated
public Path getRunDir(Long workflowId, String runId) {
return getRunDir(workflowId, null, runId);
}
/**
* 获取基路径
*/
public Path getBasePath() {
return basePath;
}
/**
* 校验 runId 格式,防止路径穿越
*/
private void validateRunId(String runId) {
if (runId == null || !RUN_ID_PATTERN.matcher(runId).matches()) {
throw new IllegalArgumentException("非法 runId: " + runId);
}
}
private Path resolveByName(String workflowName, String runId) {
Path runDir = basePath.resolve(workflowName).resolve(runId).normalize();
if (!runDir.startsWith(basePath)) {
throw new IllegalArgumentException("解析后的 runId 路径越界: " + runDir);
}
return runDir;
}
private Path resolveById(Long workflowId, String runId) {
Path runDir = basePath.resolve(String.valueOf(workflowId)).resolve(runId).normalize();
if (!runDir.startsWith(basePath)) {
throw new IllegalArgumentException("解析后的 runId 路径越界: " + runDir);
}
return runDir;
}
}