mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(cron): isolate cron jobs by workspace (issue #37)
This commit is contained in:
parent
bd36f87cc9
commit
0a5b1989f3
@ -24,55 +24,75 @@ public class CronJobController {
|
|||||||
|
|
||||||
private final CronJobService cronJobService;
|
private final CronJobService cronJobService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
|
||||||
|
* axios interceptor already injects it). Service-layer filtering is
|
||||||
|
* required because {@link vip.mate.config.WorkspaceAccessInterceptor}
|
||||||
|
* skips its membership check entirely for global {@code admin} users —
|
||||||
|
* relying on the interceptor alone leaks cron jobs across workspaces.
|
||||||
|
*/
|
||||||
|
private static final long DEFAULT_WORKSPACE_ID = 1L;
|
||||||
|
|
||||||
@Operation(summary = "获取定时任务列表")
|
@Operation(summary = "获取定时任务列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
public R<List<CronJobDTO>> list() {
|
public R<List<CronJobDTO>> list(
|
||||||
return R.ok(cronJobService.list());
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
return R.ok(cronJobService.list(resolve(workspaceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取定时任务详情")
|
@Operation(summary = "获取定时任务详情")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@RequireWorkspaceRole("viewer")
|
@RequireWorkspaceRole("viewer")
|
||||||
public R<CronJobDTO> get(@PathVariable Long id) {
|
public R<CronJobDTO> get(@PathVariable Long id,
|
||||||
return R.ok(cronJobService.getById(id));
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
return R.ok(cronJobService.getById(id, resolve(workspaceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "创建定时任务")
|
@Operation(summary = "创建定时任务")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
public R<CronJobDTO> create(@RequestBody CronJobDTO dto) {
|
public R<CronJobDTO> create(@RequestBody CronJobDTO dto,
|
||||||
return R.ok(cronJobService.create(dto));
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
return R.ok(cronJobService.create(dto, resolve(workspaceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新定时任务")
|
@Operation(summary = "更新定时任务")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto) {
|
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto,
|
||||||
return R.ok(cronJobService.update(id, dto));
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
return R.ok(cronJobService.update(id, dto, resolve(workspaceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除定时任务")
|
@Operation(summary = "删除定时任务")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequireWorkspaceRole("admin")
|
@RequireWorkspaceRole("admin")
|
||||||
public R<Void> delete(@PathVariable Long id) {
|
public R<Void> delete(@PathVariable Long id,
|
||||||
cronJobService.delete(id);
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
cronJobService.delete(id, resolve(workspaceId));
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "启用/禁用定时任务")
|
@Operation(summary = "启用/禁用定时任务")
|
||||||
@PutMapping("/{id}/toggle")
|
@PutMapping("/{id}/toggle")
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
|
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled,
|
||||||
cronJobService.toggle(id, enabled);
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
cronJobService.toggle(id, enabled, resolve(workspaceId));
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "立即执行定时任务")
|
@Operation(summary = "立即执行定时任务")
|
||||||
@PostMapping("/{id}/run")
|
@PostMapping("/{id}/run")
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
public R<Void> runNow(@PathVariable Long id) {
|
public R<Void> runNow(@PathVariable Long id,
|
||||||
cronJobService.runNow(id);
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
|
cronJobService.runNow(id, resolve(workspaceId));
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long resolve(Long headerWorkspaceId) {
|
||||||
|
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import java.time.LocalDateTime;
|
|||||||
public class CronJobDTO {
|
public class CronJobDTO {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
/** Out-only: workspace ID stamped by the server from X-Workspace-Id (RFC-083). */
|
||||||
|
private Long workspaceId;
|
||||||
private String name;
|
private String name;
|
||||||
private String cronExpression;
|
private String cronExpression;
|
||||||
private String timezone;
|
private String timezone;
|
||||||
@ -54,6 +56,7 @@ public class CronJobDTO {
|
|||||||
public static CronJobDTO from(CronJobEntity entity) {
|
public static CronJobDTO from(CronJobEntity entity) {
|
||||||
CronJobDTO dto = new CronJobDTO();
|
CronJobDTO dto = new CronJobDTO();
|
||||||
dto.setId(entity.getId());
|
dto.setId(entity.getId());
|
||||||
|
dto.setWorkspaceId(entity.getWorkspaceId());
|
||||||
dto.setName(entity.getName());
|
dto.setName(entity.getName());
|
||||||
dto.setCronExpression(entity.getCronExpression());
|
dto.setCronExpression(entity.getCronExpression());
|
||||||
dto.setTimezone(entity.getTimezone());
|
dto.setTimezone(entity.getTimezone());
|
||||||
|
|||||||
@ -18,6 +18,9 @@ public class CronJobEntity {
|
|||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/** Workspace ID this cron job belongs to (RFC-083 / V62; existing rows default to 1). */
|
||||||
|
private Long workspaceId;
|
||||||
|
|
||||||
/** 任务名称 */
|
/** 任务名称 */
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
|||||||
@ -17,13 +17,16 @@ import java.util.List;
|
|||||||
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-063r §2.14: list cron jobs together with their most-recent
|
* RFC-063r §2.14 + RFC-083: list cron jobs in the given workspace together
|
||||||
* delivery status / error (subquery against {@code mate_cron_job_run}).
|
* with their most-recent delivery status / error (subquery against
|
||||||
|
* {@code mate_cron_job_run}).
|
||||||
*
|
*
|
||||||
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
|
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
|
||||||
* subquery, so the same SQL is portable across the two profiles. Index
|
* subquery, so the same SQL is portable across the two profiles. Index
|
||||||
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
|
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
|
||||||
* by V1 baseline migration) makes the subquery cheap.
|
* by V1 baseline migration) makes the subquery cheap; V62 adds
|
||||||
|
* {@code idx_cron_job_workspace(workspace_id, deleted)} for the outer
|
||||||
|
* filter.
|
||||||
*
|
*
|
||||||
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
||||||
* to mirror the existing {@code list()} ordering.
|
* to mirror the existing {@code list()} ordering.
|
||||||
@ -37,14 +40,16 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
|||||||
WHERE r.cron_job_id = j.id
|
WHERE r.cron_job_id = j.id
|
||||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||||
FROM mate_cron_job j
|
FROM mate_cron_job j
|
||||||
WHERE j.deleted = 0
|
WHERE j.deleted = 0 AND j.workspace_id = #{workspaceId}
|
||||||
ORDER BY j.create_time DESC
|
ORDER BY j.create_time DESC
|
||||||
""")
|
""")
|
||||||
List<CronJobEntity> selectListWithDeliveryStatus();
|
List<CronJobEntity> selectListWithDeliveryStatus(@Param("workspaceId") Long workspaceId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-063r §2.14: per-job variant for the detail page. Same subquery
|
* RFC-063r §2.14 + RFC-083: per-job variant for the detail page. Same
|
||||||
* pattern, restricted to a single id.
|
* subquery pattern, restricted to a single id within the given workspace
|
||||||
|
* (cross-workspace access returns null → caller throws not_found, matching
|
||||||
|
* the "deleted" shape so workspace existence isn't enumerable).
|
||||||
*/
|
*/
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT j.*,
|
SELECT j.*,
|
||||||
@ -55,7 +60,17 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
|||||||
WHERE r.cron_job_id = j.id
|
WHERE r.cron_job_id = j.id
|
||||||
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||||
FROM mate_cron_job j
|
FROM mate_cron_job j
|
||||||
WHERE j.id = #{id} AND j.deleted = 0
|
WHERE j.id = #{id} AND j.deleted = 0 AND j.workspace_id = #{workspaceId}
|
||||||
""")
|
""")
|
||||||
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id);
|
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id,
|
||||||
|
@Param("workspaceId") Long workspaceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-083: workspace-scoped lookup for write paths (update / delete /
|
||||||
|
* toggle / runNow). Skips the delivery-status subquery — those paths
|
||||||
|
* don't need it and pay for the correlated lookup otherwise.
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM mate_cron_job WHERE id = #{id} AND deleted = 0 AND workspace_id = #{workspaceId}")
|
||||||
|
CronJobEntity selectByIdAndWorkspace(@Param("id") Long id,
|
||||||
|
@Param("workspaceId") Long workspaceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,6 +93,10 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
scheduler.setThreadNamePrefix("cron-job-");
|
scheduler.setThreadNamePrefix("cron-job-");
|
||||||
scheduler.initialize();
|
scheduler.initialize();
|
||||||
|
|
||||||
|
// RFC-083: scheduler is process-global by design — load every enabled
|
||||||
|
// job across all workspaces. Do NOT filter by workspace_id here,
|
||||||
|
// otherwise jobs in workspace B stop firing whenever the active UI
|
||||||
|
// workspace is A. Workspace isolation lives in the CRUD paths only.
|
||||||
List<CronJobEntity> enabledJobs = cronJobMapper.selectList(
|
List<CronJobEntity> enabledJobs = cronJobMapper.selectList(
|
||||||
new LambdaQueryWrapper<CronJobEntity>()
|
new LambdaQueryWrapper<CronJobEntity>()
|
||||||
.eq(CronJobEntity::getEnabled, true));
|
.eq(CronJobEntity::getEnabled, true));
|
||||||
@ -114,11 +118,12 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
|
|
||||||
// ==================== CRUD ====================
|
// ==================== CRUD ====================
|
||||||
|
|
||||||
public List<CronJobDTO> list() {
|
public List<CronJobDTO> list(Long workspaceId) {
|
||||||
// RFC-063r §2.14: use the variant that aggregates the most-recent
|
// RFC-063r §2.14: use the variant that aggregates the most-recent
|
||||||
// delivery_status from mate_cron_job_run so the list page can
|
// delivery_status from mate_cron_job_run so the list page can
|
||||||
// render the "最近投递" badge without a per-row N+1 query.
|
// render the "最近投递" badge without a per-row N+1 query.
|
||||||
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus();
|
// RFC-083: scoped to the caller's workspace.
|
||||||
|
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId);
|
||||||
|
|
||||||
// 批量加载 Agent 名称
|
// 批量加载 Agent 名称
|
||||||
List<Long> agentIds = entities.stream()
|
List<Long> agentIds = entities.stream()
|
||||||
@ -152,10 +157,13 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public CronJobDTO getById(Long id) {
|
public CronJobDTO getById(Long id, Long workspaceId) {
|
||||||
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
|
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
|
||||||
// subquery shape, restricted to one id.
|
// subquery shape, restricted to one id.
|
||||||
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id);
|
// RFC-083: scoped to the caller's workspace; cross-workspace ID access
|
||||||
|
// surfaces as not_found (same shape as deleted) so workspace existence
|
||||||
|
// is not enumerable.
|
||||||
|
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id, workspaceId);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -168,12 +176,15 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CronJobDTO create(CronJobDTO dto) {
|
public CronJobDTO create(CronJobDTO dto, Long workspaceId) {
|
||||||
validateDto(dto);
|
validateDto(dto);
|
||||||
// toSpringCron 校验表达式合法性,结果复用于后续 calcNextRunTime 和 register
|
// toSpringCron 校验表达式合法性,结果复用于后续 calcNextRunTime 和 register
|
||||||
String springCron = toSpringCron(dto.getCronExpression());
|
String springCron = toSpringCron(dto.getCronExpression());
|
||||||
|
|
||||||
CronJobEntity entity = dto.toEntity();
|
CronJobEntity entity = dto.toEntity();
|
||||||
|
// RFC-083: workspace stamped server-side from X-Workspace-Id; never
|
||||||
|
// trust a client-supplied value (DTO.toEntity intentionally drops it).
|
||||||
|
entity.setWorkspaceId(workspaceId);
|
||||||
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
|
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
|
||||||
if (entity.getTaskType() == null) entity.setTaskType("text");
|
if (entity.getTaskType() == null) entity.setTaskType("text");
|
||||||
if (entity.getEnabled() == null) entity.setEnabled(true);
|
if (entity.getEnabled() == null) entity.setEnabled(true);
|
||||||
@ -186,11 +197,13 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
register(entity);
|
register(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getById(entity.getId());
|
return getById(entity.getId(), workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CronJobDTO update(Long id, CronJobDTO dto) {
|
public CronJobDTO update(Long id, CronJobDTO dto, Long workspaceId) {
|
||||||
CronJobEntity existing = cronJobMapper.selectById(id);
|
// RFC-083: scoped lookup — cross-workspace updates 404 the same as
|
||||||
|
// deleted rows.
|
||||||
|
CronJobEntity existing = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -222,11 +235,11 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
schedulerLock.unlock();
|
schedulerLock.unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
return getById(id);
|
return getById(id, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(Long id) {
|
public void delete(Long id, Long workspaceId) {
|
||||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -239,8 +252,8 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
cronJobMapper.deleteById(id);
|
cronJobMapper.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void toggle(Long id, Boolean enabled) {
|
public void toggle(Long id, Boolean enabled, Long workspaceId) {
|
||||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -267,8 +280,8 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void runNow(Long id) {
|
public void runNow(Long id, Long workspaceId) {
|
||||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -309,7 +322,8 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}), trigger);
|
}), trigger);
|
||||||
scheduledTasks.put(job.getId(), future);
|
scheduledTasks.put(job.getId(), future);
|
||||||
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
log.info("[CronJob] Registered job {} ({}) ws={}, cron={}, tz={}",
|
||||||
|
job.getId(), job.getName(), job.getWorkspaceId(),
|
||||||
job.getCronExpression(), job.getTimezone());
|
job.getCronExpression(), job.getTimezone());
|
||||||
} finally {
|
} finally {
|
||||||
schedulerLock.unlock();
|
schedulerLock.unlock();
|
||||||
|
|||||||
@ -81,7 +81,11 @@ public class CronJobTool {
|
|||||||
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
|
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
|
||||||
propagateChannelBinding(dto, origin);
|
propagateChannelBinding(dto, origin);
|
||||||
|
|
||||||
CronJobDTO created = cronJobService.create(dto);
|
// RFC-083: stamp workspace from the originating ChatOrigin so the
|
||||||
|
// cron job is created in the agent's current workspace; fall back
|
||||||
|
// to the default workspace when origin is unscoped (legacy paths).
|
||||||
|
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||||
|
CronJobDTO created = cronJobService.create(dto, workspaceId);
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("success", true);
|
result.set("success", true);
|
||||||
@ -101,9 +105,12 @@ public class CronJobTool {
|
|||||||
|
|
||||||
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
|
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
|
||||||
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
|
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
|
||||||
public String list_cron_jobs() {
|
public String list_cron_jobs(@Nullable ToolContext ctx) {
|
||||||
try {
|
try {
|
||||||
List<CronJobDTO> jobs = cronJobService.list();
|
// RFC-083: scope to the originating workspace so an agent only
|
||||||
|
// sees the cron jobs of the workspace it's running in.
|
||||||
|
Long workspaceId = workspaceFromContext(ctx);
|
||||||
|
List<CronJobDTO> jobs = cronJobService.list(workspaceId);
|
||||||
JSONArray arr = new JSONArray();
|
JSONArray arr = new JSONArray();
|
||||||
for (CronJobDTO job : jobs) {
|
for (CronJobDTO job : jobs) {
|
||||||
JSONObject obj = new JSONObject();
|
JSONObject obj = new JSONObject();
|
||||||
@ -132,10 +139,13 @@ public class CronJobTool {
|
|||||||
+ "Use list_cron_jobs first to find the job ID.")
|
+ "Use list_cron_jobs first to find the job ID.")
|
||||||
public String toggle_cron_job(
|
public String toggle_cron_job(
|
||||||
@ToolParam(description = "Job ID (number)") Long jobId,
|
@ToolParam(description = "Job ID (number)") Long jobId,
|
||||||
@ToolParam(description = "true to enable, false to disable") Boolean enabled) {
|
@ToolParam(description = "true to enable, false to disable") Boolean enabled,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
try {
|
try {
|
||||||
cronJobService.toggle(jobId, enabled);
|
// RFC-083: scope toggle to the originating workspace.
|
||||||
CronJobDTO updated = cronJobService.getById(jobId);
|
Long workspaceId = workspaceFromContext(ctx);
|
||||||
|
cronJobService.toggle(jobId, enabled, workspaceId);
|
||||||
|
CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("success", true);
|
result.set("success", true);
|
||||||
result.set("jobId", jobId);
|
result.set("jobId", jobId);
|
||||||
@ -153,11 +163,14 @@ public class CronJobTool {
|
|||||||
@Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. "
|
@Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. "
|
||||||
+ "Use list_cron_jobs first to find the job ID.")
|
+ "Use list_cron_jobs first to find the job ID.")
|
||||||
public String delete_cron_job(
|
public String delete_cron_job(
|
||||||
@ToolParam(description = "Job ID (number) to delete") Long jobId) {
|
@ToolParam(description = "Job ID (number) to delete") Long jobId,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
try {
|
try {
|
||||||
CronJobDTO job = cronJobService.getById(jobId);
|
// RFC-083: scope delete to the originating workspace.
|
||||||
|
Long workspaceId = workspaceFromContext(ctx);
|
||||||
|
CronJobDTO job = cronJobService.getById(jobId, workspaceId);
|
||||||
String jobName = job.getName();
|
String jobName = job.getName();
|
||||||
cronJobService.delete(jobId);
|
cronJobService.delete(jobId, workspaceId);
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("success", true);
|
result.set("success", true);
|
||||||
result.set("deleted", jobName);
|
result.set("deleted", jobName);
|
||||||
@ -175,6 +188,17 @@ public class CronJobTool {
|
|||||||
return JSONUtil.toJsonPrettyStr(result);
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-083: resolve the workspace ID from the originating ChatOrigin so
|
||||||
|
* cron-tool reads/writes are scoped to the agent's current workspace.
|
||||||
|
* Falls back to the default workspace (1) when origin is unscoped — same
|
||||||
|
* behaviour as the controller-layer {@code resolve()} helper.
|
||||||
|
*/
|
||||||
|
private Long workspaceFromContext(@Nullable ToolContext ctx) {
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-063r §2.4: propagate the originating channel binding into the cron
|
* RFC-063r §2.4: propagate the originating channel binding into the cron
|
||||||
* job DTO so PR-3's delivery dispatcher can route results back to the
|
* job DTO so PR-3's delivery dispatcher can route results back to the
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- RFC-083: workspace-isolate cron jobs
|
||||||
|
-- (issue: https://github.com/matevip/mateclaw/issues/37).
|
||||||
|
|
||||||
|
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted);
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
-- RFC-083: workspace-isolate cron jobs
|
||||||
|
-- (issue: https://github.com/matevip/mateclaw/issues/37).
|
||||||
|
|
||||||
|
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_cron_job'
|
||||||
|
AND COLUMN_NAME = 'workspace_id');
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_cron_job ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_cron_job'
|
||||||
|
AND INDEX_NAME = 'idx_cron_job_workspace');
|
||||||
|
SET @stmt := IF(@idx_exists = 0,
|
||||||
|
'CREATE INDEX idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted)',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
Loading…
Reference in New Issue
Block a user