Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
# syntax=docker/dockerfile:1.7
FROM eclipse-temurin:25-jdk@sha256:e787e08ef76f4c16866108cd7f9fcd96a68eef3ac6cc76866897d4d02d5a2262 AS build
FROM eclipse-temurin:25-jdk@sha256:119a3d18f160a3e7655a66034d0f43beee31cd7b3b9142d57a5de29772011de6 AS build
WORKDIR /workspace
COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon
COPY src ./src
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:25-jre@sha256:f9e65324a37f28209ce7dd0e5149a7aa954520ed936fb87813cf6ded2400a112
RUN rm -- /usr/bin/pebble \
FROM eclipse-temurin:25-jre@sha256:8da0490fa9a3c26867012019565948eef0ee69438f5c75ac28146967bae984b5
RUN apt-get update \
&& apt-get install --only-upgrade --yes --no-install-recommends \
libssl3t64 \
openssl \
openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/* \
&& rm -- /usr/bin/pebble \
&& useradd --system --uid 10001 --create-home app
USER 10001
WORKDIR /app
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Сервис хранит действия Portable Agent и безопасно выполняет подтверждённые действия. Он принимает
действие, сохраняет payload вместе с записью outbox и передаёт идентификатор в Temporal. Worker ждёт
решение пользователя и после `CONFIRM` вызывает MCP Gateway. В первом продуктовом срезе разрешено
только `calendar.create_event` через `fake-calendar`.
решение пользователя и после `CONFIRM` вызывает MCP Gateway. Сейчас разрешено действие
`calendar.create_event` через `fake-calendar` или `google-calendar`.

## Стек

Expand Down Expand Up @@ -54,7 +54,7 @@ HTTP-интерфейс Action API и сетевые модели MCP Gateway с
contracts:

```powershell
pwsh ./scripts/update-contract.ps1 -Version 2.0.0
pwsh ./scripts/update-contract.ps1 -Version 3.0.0
```

Миграция `V2` один раз удаляет тестовые записи старого pre-MVP-каркаса: в схеме `V1` payload не
Expand All @@ -67,7 +67,7 @@ pwsh ./scripts/update-contract.ps1 -Version 2.0.0
`start`, `succeed` и `fail` также идемпотентны: повтор не меняет версию и время действия. Другой
`eventId` после успеха отклоняется как конфликт.

Action API и outbound-вызов MCP Gateway соответствуют bundle `portable-agent/contracts` версии `2.6.0`.
Action API и outbound-вызов MCP Gateway соответствуют bundle `portable-agent/contracts` версии `3.0.0`.
При выполнении `context.actorId` берётся из сохранённого Action и передаётся отдельно от аргументов
MCP tool. Модель и пользовательский payload не могут подменить владельца подключения.

Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ group = "dev.portableagent"
version = "0.1.0-SNAPSHOT"

extra["tomcat.version"] = "11.0.25"
extra["jackson-2-bom.version"] = "2.21.6"
extra["jackson-bom.version"] = "3.1.6"

java {
toolchain {
Expand Down
8 changes: 4 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
- `GET /api/v1/actions/{actionId}` — получить действие своего tenant;
- `POST /api/v1/actions/{actionId}/decisions` — передать решение.

В первом продуктовом срезе создание принимает только `calendar.create_event` с коннектором
`fake-calendar`. Ответ содержит сохранённый `payload` и его `payloadHash`. Повтор с тем же `requestKey`
возвращает ранее созданное действие вместе с тем же payload.
Создание принимает `calendar.create_event` с коннектором `fake-calendar` или `google-calendar`.
Ответ содержит сохранённый `payload` и его `payloadHash`. Повтор с тем же `requestKey` возвращает ранее
созданное действие вместе с тем же payload.

Точный повтор решения с тем же `payloadHash` возвращает текущее действие без новой записи в БД.
Попытка изменить уже принятое решение или подменить hash возвращает конфликт.

Сервис использует контракт `portable-agent/contracts v2.6.0`. После успешного выполнения ответ содержит
Сервис использует контракт `portable-agent/contracts v3.0.0`. После успешного выполнения ответ содержит
`result.eventId`. До состояния `SUCCEEDED` поле `result` отсутствует.

Внутренний `POST /api/v1/calls` MCP Gateway также берётся из этого release. Его request/response DTO
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ OpenAPI из одного release `portable-agent/contracts` хранится в

## Текущий продуктовый срез

`ActionService` разрешает только `calendar.create_event` через `fake-calendar`. Проверка находится в
service-слое до вычисления hash и сохранения нового действия. Повтор с существующим `requestKey`
по-прежнему возвращает ранее сохранённое действие. Конкурентные повторы защищены ограничением БД и
jOOQ `ON CONFLICT DO NOTHING`; запись outbox создаёт только запрос, который сохранил действие.
`ActionService` передаёт проверку в `ActionRules`. Реестр выбирает правило по `kind` через `Map`, поэтому
новое действие добавляется отдельной стратегией, а не новой веткой `if`. `CalendarActionRule` разрешает
`fake-calendar` и `google-calendar` и проверяет общий payload встречи до вычисления hash и сохранения.
Повтор с существующим `requestKey` по-прежнему возвращает ранее сохранённое действие. Конкурентные
повторы защищены ограничением БД и jOOQ `ON CONFLICT DO NOTHING`; запись outbox создаёт только запрос,
который сохранил действие.
Точный payload хранится в PostgreSQL как `JSONB` и возвращается вместе с `payloadHash`. Поэтому виджет
и будущий workflow используют данные сохранённого действия, а не новый ответ AI.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package dev.portableagent.action.service;

import java.util.Map;

public interface ActionRule {
String kind();

void check(String connector, Map<String, Object> payload);
}
24 changes: 24 additions & 0 deletions src/main/java/dev/portableagent/action/service/ActionRules.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.portableagent.action.service;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class ActionRules {
private final Map<String, ActionRule> byKind;

public ActionRules(List<ActionRule> rules) {
this.byKind = rules.stream().collect(Collectors.toUnmodifiableMap(ActionRule::kind, Function.identity()));
}

public void check(CreateActionCommand request) {
var rule = byKind.get(request.kind());
if (rule == null) {
throw new IllegalArgumentException("Unsupported action kind: " + request.kind());
}
rule.check(request.connector(), request.payload());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,24 @@

@Service
public class ActionService {
private static final String CALENDAR_ACTION = "calendar.create_event";
private static final String FAKE_CALENDAR = "fake-calendar";
private static final int CHANGE_TRIES = 3;

private final ActionRepository actionRepository;
private final OutboxRepository outboxRepository;
private final PayloadHash payloadHash;
private final CalendarInputCheck calendarInputCheck;
private final ActionRules actionRules;
private final Clock clock;

public ActionService(
ActionRepository actionRepository,
OutboxRepository outboxRepository,
PayloadHash payloadHash,
CalendarInputCheck calendarInputCheck,
ActionRules actionRules,
Clock clock) {
this.actionRepository = actionRepository;
this.outboxRepository = outboxRepository;
this.payloadHash = payloadHash;
this.calendarInputCheck = calendarInputCheck;
this.actionRules = actionRules;
this.clock = clock;
}

Expand All @@ -46,8 +44,7 @@ public Action create(UUID tenantId, UUID userId, CreateActionCommand request) {
return oldAction.get();
}

checkAllowed(request);
calendarInputCheck.check(request.payload());
actionRules.check(request);

var now = clock.instant();
var action = Action.create(
Expand All @@ -68,15 +65,6 @@ public Action create(UUID tenantId, UUID userId, CreateActionCommand request) {
return action;
}

private void checkAllowed(CreateActionCommand request) {
if (!CALENDAR_ACTION.equals(request.kind())) {
throw new IllegalArgumentException("Only calendar.create_event is supported");
}
if (!FAKE_CALENDAR.equals(request.connector())) {
throw new IllegalArgumentException("Only fake-calendar is supported");
}
}

@Transactional(readOnly = true)
public Action get(UUID tenantId, UUID actionId) {
return actionRepository.findById(tenantId, actionId).orElseThrow(() -> new ActionNotFound(actionId));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package dev.portableagent.action.service;

import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;

@Component
public class CalendarActionRule implements ActionRule {
private static final String KIND = "calendar.create_event";
private static final Set<String> CONNECTORS = Set.of("fake-calendar", "google-calendar");

private final CalendarInputCheck inputCheck;

public CalendarActionRule(CalendarInputCheck inputCheck) {
this.inputCheck = inputCheck;
}

@Override
public String kind() {
return KIND;
}

@Override
public void check(String connector, Map<String, Object> payload) {
if (!CONNECTORS.contains(connector)) {
throw new IllegalArgumentException("Unsupported connector: " + connector);
}
inputCheck.check(payload);
}
}
4 changes: 2 additions & 2 deletions src/main/openapi/action-api.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Portable Agent Action API
version: 2.6.0
version: 3.0.0
description: Создание, просмотр и подтверждение безопасных действий агента.
license:
name: Apache-2.0
Expand Down Expand Up @@ -103,7 +103,7 @@ components:
enum: [calendar.create_event]
connector:
type: string
enum: [fake-calendar]
enum: [fake-calendar, google-calendar]
payload:
$ref: "#/components/schemas/CalendarCreateEventPayload"
requestKey:
Expand Down
2 changes: 1 addition & 1 deletion src/main/openapi/mcp-gateway-api.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Portable Agent MCP Gateway API
version: 2.6.0
version: 3.0.0
description: Безопасный внутренний вызов разрешённых tools в настроенных MCP-сервисах.
license:
name: Apache-2.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class ActionServiceTest {

@BeforeEach
void setUp() {
service = new ActionService(actionRepository, outboxRepository, payloadHash, calendarInputCheck, clock);
var rules = new ActionRules(java.util.List.of(new CalendarActionRule(calendarInputCheck)));
service = new ActionService(actionRepository, outboxRepository, payloadHash, rules, clock);
}

@Test
Expand Down Expand Up @@ -121,22 +122,37 @@ void create_whenKindIsNotCalendar_shouldRejectRequest() {

assertThatThrownBy(() -> service.create(tenantId, UUID.randomUUID(), request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Only calendar.create_event is supported");
.hasMessage("Unsupported action kind: task.create");

verify(actionRepository, never()).saveIfMissing(any(Action.class));
verifyNoInteractions(outboxRepository, payloadHash);
}

@Test
void create_whenConnectorIsNotFakeCalendar_shouldRejectRequest() {
void create_whenConnectorIsGoogleCalendar_shouldSaveAction() {
var tenantId = UUID.randomUUID();
var request = new CreateActionCommand(
"calendar.create_event", "google-calendar", Map.of("title", "Demo"), "request-123");
when(actionRepository.findByRequestKey(tenantId, request.requestKey())).thenReturn(Optional.empty());
when(payloadHash.make(request.payload())).thenReturn("a".repeat(64));
when(actionRepository.saveIfMissing(any(Action.class))).thenReturn(true);

var result = service.create(tenantId, UUID.randomUUID(), request);

assertThat(result.getConnector()).isEqualTo("google-calendar");
verify(actionRepository).saveIfMissing(result);
}

@Test
void create_whenConnectorIsUnknown_shouldRejectRequest() {
var tenantId = UUID.randomUUID();
var request =
new CreateActionCommand("calendar.create_event", "unknown", Map.of("title", "Demo"), "request-123");
when(actionRepository.findByRequestKey(tenantId, request.requestKey())).thenReturn(Optional.empty());

assertThatThrownBy(() -> service.create(tenantId, UUID.randomUUID(), request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Only fake-calendar is supported");
.hasMessage("Unsupported connector: unknown");

verify(actionRepository, never()).saveIfMissing(any(Action.class));
verifyNoInteractions(outboxRepository, payloadHash);
Expand Down
Loading