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
35 changes: 35 additions & 0 deletions docs/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,41 @@ type ResolvePermissionParams struct {

---

## Method: gateway.approvePlan

- Stability: Stable
- Auth Required: Yes
- Request Schema:

```go
type ApprovePlanParams struct {
SessionID string `json:"session_id"` // MUST
PlanID string `json:"plan_id"` // MUST
Revision int `json:"revision"` // MUST > 0
}
```

- Response Schema:

```json
{
"type": "ack",
"action": "approve_plan",
"session_id": "session-1",
"payload": {
"plan_id": "plan-1",
"revision": 2,
"status": "approved"
}
}
```

- Semantics:
- 仅批准当前会话中匹配 `plan_id + revision` 的 `draft` 计划。
- 成功后客户端可再调用 `gateway.run({ "mode": "build" })` 执行已批准计划。

---

## Method: gateway.userQuestionAnswer

- Stability: Beta
Expand Down
41 changes: 39 additions & 2 deletions docs/reference/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,44 @@ Observation:

---

## 15. wake.openUrl
## 15. gateway.approvePlan

Method: `gateway.approvePlan`
Stability: `Stable`
Auth Required: `Yes`

Request Schema:

```go
type ApprovePlanParams struct {
SessionID string `json:"session_id"` // MUST
PlanID string `json:"plan_id"` // MUST
Revision int `json:"revision"` // MUST > 0
}
```

Response Schema:

```json
{
"type": "ack",
"action": "approve_plan",
"session_id": "session-1",
"payload": {
"plan_id": "plan-1",
"revision": 2,
"status": "approved"
}
}
```

Semantics:
1. Only the current session plan matching `plan_id + revision` and `draft` status can be approved.
2. After success, clients can call `gateway.run` with `mode: "build"` to execute the approved plan.

---

## 16. wake.openUrl

Method: `wake.openUrl`
Stability: `Experimental`
Expand Down Expand Up @@ -828,7 +865,7 @@ Observation:

---

## 16. gateway.event(服务端通知)
## 17. gateway.event(服务端通知)

Method: `gateway.event`
Stability: `Stable`
Expand Down
31 changes: 31 additions & 0 deletions internal/cli/gateway_runtime_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,37 @@ func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input
})
}

// ApprovePlan 将网关计划批准请求转换为 runtime 当前计划批准输入。
func (b *gatewayRuntimePortBridge) ApprovePlan(
ctx context.Context,
input gateway.ApprovePlanInput,
) (gateway.ApprovePlanResult, error) {
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
return gateway.ApprovePlanResult{}, err
}
approver, ok := b.runtime.(agentruntime.PlanApprover)
if !ok {
return gateway.ApprovePlanResult{}, fmt.Errorf("gateway runtime bridge: runtime does not support plan approval")
}
sessionID := strings.TrimSpace(input.SessionID)
planID := strings.TrimSpace(input.PlanID)
if err := approver.ApproveCurrentPlan(ctx, agentruntime.ApproveCurrentPlanInput{
SessionID: sessionID,
PlanID: planID,
Revision: input.Revision,
}); err != nil {
if agentruntime.IsPlanApprovalInvalidError(err) {
return gateway.ApprovePlanResult{}, fmt.Errorf("%w: %v", gateway.ErrRuntimeInvalidAction, err)
}
return gateway.ApprovePlanResult{}, err
}
return gateway.ApprovePlanResult{
PlanID: planID,
Revision: input.Revision,
Status: "approved",
}, nil
}

// ResolveUserQuestion 将网关 ask_user 回答转发到 runtime。
func (b *gatewayRuntimePortBridge) ResolveUserQuestion(ctx context.Context, input gateway.UserQuestionAnswerInput) error {
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
Expand Down
109 changes: 109 additions & 0 deletions internal/cli/gateway_runtime_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ type runtimeStub struct {
checkpointDiffErr error
}

type runtimePlanApproverStub struct {
*runtimeStub
approveInput agentruntime.ApproveCurrentPlanInput
approveErr error
}

const testBridgeSubjectID = bridgeLocalSubjectID

func (s *runtimeStub) Submit(_ context.Context, input agentruntime.PrepareInput) error {
Expand Down Expand Up @@ -132,6 +138,14 @@ func (s *runtimeStub) ResolvePermission(_ context.Context, input agentruntime.Pe
return s.permissionErr
}

func (s *runtimePlanApproverStub) ApproveCurrentPlan(
_ context.Context,
input agentruntime.ApproveCurrentPlanInput,
) error {
s.approveInput = input
return s.approveErr
}

func (s *runtimeStub) ResolveUserQuestion(_ context.Context, input agentruntime.UserQuestionResolutionInput) error {
s.userQuestionInput = input
return s.userQuestionErr
Expand Down Expand Up @@ -1075,6 +1089,101 @@ func TestGatewayRuntimePortBridgeListSessionTodosAndSnapshot(t *testing.T) {
})
}

func TestGatewayRuntimePortBridgeApprovePlan(t *testing.T) {
runtimeSvc := &runtimePlanApproverStub{
runtimeStub: &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
}
bridge, err := newGatewayRuntimePortBridge(context.Background(), runtimeSvc, testSessionStore)
if err != nil {
t.Fatalf("new bridge: %v", err)
}
t.Cleanup(func() { _ = bridge.Close() })

result, err := bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{
SubjectID: testBridgeSubjectID,
SessionID: " session-1 ",
PlanID: " plan-1 ",
Revision: 3,
})
if err != nil {
t.Fatalf("approve_plan: %v", err)
}
if runtimeSvc.approveInput.SessionID != "session-1" || runtimeSvc.approveInput.PlanID != "plan-1" || runtimeSvc.approveInput.Revision != 3 {
t.Fatalf("approve input = %#v, want trimmed session/plan revision", runtimeSvc.approveInput)
}
if result.PlanID != "plan-1" || result.Revision != 3 || result.Status != "approved" {
t.Fatalf("approve result = %#v, want approved plan-1 revision 3", result)
}
}

func TestGatewayRuntimePortBridgeApprovePlanUnsupportedRuntime(t *testing.T) {
bridge, err := newGatewayRuntimePortBridge(
context.Background(),
&runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
testSessionStore,
)
if err != nil {
t.Fatalf("new bridge: %v", err)
}
t.Cleanup(func() { _ = bridge.Close() })

_, err = bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{
SubjectID: testBridgeSubjectID,
SessionID: "session-1",
PlanID: "plan-1",
Revision: 1,
})
if err == nil || !strings.Contains(err.Error(), "runtime does not support plan approval") {
t.Fatalf("approve_plan unsupported error = %v", err)
}
}

func TestGatewayRuntimePortBridgeApprovePlanInvalidAction(t *testing.T) {
runtimeSvc := &runtimePlanApproverStub{
runtimeStub: &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
approveErr: agentruntime.ErrPlanApprovalRevisionMismatch,
}
bridge, err := newGatewayRuntimePortBridge(context.Background(), runtimeSvc, testSessionStore)
if err != nil {
t.Fatalf("new bridge: %v", err)
}
t.Cleanup(func() { _ = bridge.Close() })

_, err = bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{
SubjectID: testBridgeSubjectID,
SessionID: "session-1",
PlanID: "plan-1",
Revision: 1,
})
if !errors.Is(err, gateway.ErrRuntimeInvalidAction) {
t.Fatalf("approve_plan error = %v, want ErrRuntimeInvalidAction", err)
}
}

func TestGatewayRuntimePortBridgeApprovePlanAccessDenied(t *testing.T) {
runtimeSvc := &runtimePlanApproverStub{
runtimeStub: &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
}
bridge, err := newGatewayRuntimePortBridge(context.Background(), runtimeSvc, testSessionStore)
if err != nil {
t.Fatalf("new bridge: %v", err)
}
t.Cleanup(func() { _ = bridge.Close() })

_, err = bridge.ApprovePlan(context.Background(), gateway.ApprovePlanInput{
SubjectID: "other-subject",
SessionID: "session-1",
PlanID: "plan-1",
Revision: 1,
})
if !errors.Is(err, gateway.ErrRuntimeAccessDenied) {
t.Fatalf("approve_plan error = %v, want ErrRuntimeAccessDenied", err)
}
if runtimeSvc.approveInput.SessionID != "" {
t.Fatalf("runtime approve should not be called, input = %#v", runtimeSvc.approveInput)
}
}

func TestGatewayRuntimePortBridgeLoadSessionNotFoundBranches(t *testing.T) {
t.Parallel()

Expand Down
60 changes: 60 additions & 0 deletions internal/gateway/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -1683,11 +1683,68 @@ func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runti
}
}

// handleApprovePlanFrame 处理计划批准请求,并把能力收敛到可选 runtime 端口。
func handleApprovePlanFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame {
if runtimePort == nil {
return runtimePortUnavailableFrame(frame)
}
subjectID, subjectErr := requireAuthenticatedSubjectID(ctx)
if subjectErr != nil {
return errorFrame(frame, subjectErr)
}
approvalPort, approvalErr := requirePlanApprovalRuntimePort(runtimePort)
if approvalErr != nil {
return errorFrame(frame, approvalErr)
}

input, err := decodeApprovePlanPayload(frame.Payload)
if err != nil {
return errorFrame(frame, err)
}
input.SubjectID = subjectID
if input.SessionID == "" {
input.SessionID = strings.TrimSpace(frame.SessionID)
}
if input.SessionID == "" {
return errorFrame(frame, NewMissingRequiredFieldError("payload.session_id"))
}
if input.PlanID == "" {
return errorFrame(frame, NewMissingRequiredFieldError("payload.plan_id"))
}
if input.Revision <= 0 {
return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid approve_plan revision"))
}

callCtx, cancel := withRuntimeOperationTimeout(ctx)
defer cancel()
result, approveErr := approvalPort.ApprovePlan(callCtx, input)
if approveErr != nil {
return runtimeCallFailedFrame(callCtx, frame, approveErr, "approve_plan")
}

return MessageFrame{
Type: FrameTypeAck,
Action: FrameActionApprovePlan,
RequestID: frame.RequestID,
SessionID: input.SessionID,
Payload: result,
}
}

// runtimePortUnavailableFrame 在 runtime 未注入时返回统一错误。
func runtimePortUnavailableFrame(frame MessageFrame) MessageFrame {
return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runtime port is unavailable"))
}

// requirePlanApprovalRuntimePort 校验当前 runtime 端口是否支持计划批准能力。
func requirePlanApprovalRuntimePort(runtimePort RuntimePort) (PlanApprovalRuntimePort, *FrameError) {
approvalPort, ok := runtimePort.(PlanApprovalRuntimePort)
if !ok {
return nil, NewFrameError(ErrorCodeInternalError, "plan approval runtime port is unavailable")
}
return approvalPort, nil
}

// requireManagementRuntimePort 校验当前 runtime 端口是否支持管理面扩展能力。
func requireManagementRuntimePort(runtimePort RuntimePort) (ManagementRuntimePort, *FrameError) {
managementPort, ok := runtimePort.(ManagementRuntimePort)
Expand Down Expand Up @@ -1785,6 +1842,9 @@ func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error,
case errors.Is(err, ErrRuntimeResourceNotFound):
errorCode = ErrorCodeResourceNotFound
message = fmt.Sprintf("%s target not found", normalizedOperation)
case errors.Is(err, ErrRuntimeInvalidAction):
errorCode = ErrorCodeInvalidAction
message = fmt.Sprintf("%s invalid action", normalizedOperation)
case errors.Is(err, context.DeadlineExceeded):
errorCode = ErrorCodeTimeout
message = fmt.Sprintf("%s timed out", normalizedOperation)
Expand Down
Loading
Loading