package vip.mate.acp.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.repository.AcpEndpointMapper;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}.
*
*
Keeps three guarantees:
*
* - Builtin rows ({@code builtin=true}) cannot be hard-deleted —
* the user can only disable them. Mirrors {@code SkillService}.
* - Names are unique; {@code create} validates against the live
* (non-deleted) set.
* - {@code argsJson} / {@code envJson} round-trip through Jackson
* so the controller can hand structured data to the UI without
* leaking string-encoded JSON.
*
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
public List list() {
return mapper.selectList(new LambdaQueryWrapper()
.orderByDesc(AcpEndpointEntity::getBuiltin)
.orderByAsc(AcpEndpointEntity::getName));
}
/**
* Subset of {@link #list()} that returns only enabled rows.
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
* (one per enabled endpoint).
*/
public List listEnabled() {
return mapper.selectList(new LambdaQueryWrapper()
.eq(AcpEndpointEntity::getEnabled, true)
.orderByAsc(AcpEndpointEntity::getName));
}
public AcpEndpointEntity get(Long id) {
AcpEndpointEntity ep = mapper.selectById(id);
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
"ACP endpoint not found: " + id);
return ep;
}
public AcpEndpointEntity findByName(String name) {
return mapper.selectOne(new LambdaQueryWrapper()
.eq(AcpEndpointEntity::getName, name));
}
public AcpEndpointEntity create(AcpEndpointEntity input) {
if (input.getName() == null || input.getName().isBlank()) {
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
}
if (input.getCommand() == null || input.getCommand().isBlank()) {
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
}
if (findByName(input.getName()) != null) {
throw new MateClawException("err.acp.name_exists",
"ACP endpoint name already exists: " + input.getName());
}
// User-created rows are never builtin; default-enable false so a
// misconfigured row can't auto-spawn a process at startup.
input.setBuiltin(false);
if (input.getEnabled() == null) input.setEnabled(false);
if (input.getTrusted() == null) input.setTrusted(true);
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
input.setToolParseMode("call_title");
}
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
}
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
publish(input, AcpEndpointChangedEvent.Type.CREATED);
return input;
}
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())
&& patch.getCommand() != null
&& !patch.getCommand().equals(existing.getCommand())) {
throw new MateClawException("err.acp.builtin_command_locked",
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
}
// Allow surgical updates: only fields the caller actually set.
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
}
public void delete(Long id) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())) {
throw new MateClawException("err.acp.builtin_readonly",
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
}
mapper.deleteById(id);
log.info("Deleted ACP endpoint: {}", existing.getName());
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
}
public AcpEndpointEntity toggle(Long id, boolean enabled) {
AcpEndpointEntity existing = get(id);
existing.setEnabled(enabled);
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
return existing;
}
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
try {
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
ep.getId(), ep.getName(), type));
} catch (Exception e) {
// Listener failures must not break the CRUD path. The bridge
// will resync on the next ApplicationReady tick anyway.
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
ep.getName(), e.getMessage());
}
}
/** Persist a connection-test outcome on the row. */
public void recordTestResult(Long id, String status, String error) {
AcpEndpointEntity existing = mapper.selectById(id);
if (existing == null) return;
existing.setLastStatus(status);
existing.setLastTestedAt(LocalDateTime.now());
existing.setLastError(error);
mapper.updateById(existing);
}
public List parseArgs(AcpEndpointEntity ep) {
return parseStringList(ep.getArgsJson());
}
public Map parseEnv(AcpEndpointEntity ep) {
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
try {
return objectMapper.readValue(ep.getEnvJson(),
new TypeReference