feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s
Build web service image / build (push) Successful in 1m9s
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientsRemainIsolatedWhenOneDisconnects(t *testing.T) {
|
||||
server, err := NewServer(ServerConfig{
|
||||
DataFile: filepath.Join(t.TempDir(), "control-plane.json"),
|
||||
NodeTokens: map[string]string{"client-a": "secret-a", "client-b": "secret-b"},
|
||||
WebUsers: map[string]string{"admin": "web-secret"},
|
||||
HeartbeatTimeout: time.Minute,
|
||||
LeaseTTL: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
client := httpServer.Client()
|
||||
|
||||
login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"})
|
||||
var session struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
decodeBody(t, login, &session)
|
||||
webAuth := "Bearer " + session.AccessToken
|
||||
|
||||
register := func(nodeID, token, connectionID string) {
|
||||
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer "+token, NodeRegistration{
|
||||
NodeID: nodeID, ConnectionID: connectionID, AgentVersion: "test", ProtocolVersion: ProtocolVersion,
|
||||
Capabilities: []string{"heartbeat", "poll-tasks", "send-text"},
|
||||
Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}},
|
||||
})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("register %s status = %d: %s", nodeID, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
register("client-a", "secret-a", "connection-a-1")
|
||||
register("client-b", "secret-b", "connection-b-1")
|
||||
|
||||
create := func(nodeID, key string) TaskSubmissionResponse {
|
||||
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{
|
||||
NodeID: nodeID, AccountID: "account-a", Kind: "send-text", IdempotencyKey: key,
|
||||
Payload: json.RawMessage(`{"target_id":"target","text":"text","confirmed":true}`),
|
||||
})
|
||||
if response.Code != http.StatusAccepted {
|
||||
t.Fatalf("create %s status = %d: %s", nodeID, response.Code, response.Body.String())
|
||||
}
|
||||
var result TaskSubmissionResponse
|
||||
decodeBody(t, response, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
taskA := create("client-a", "a-1")
|
||||
taskB := create("client-b", "b-1")
|
||||
|
||||
stale := time.Now().UTC().Add(-time.Hour)
|
||||
if err := server.store.Mutate(func(state *PersistedState) error {
|
||||
node := state.Nodes["client-a"]
|
||||
node.LastHeartbeatAt = &stale
|
||||
state.Nodes["client-a"] = node
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nodesResponse := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes", webAuth, nil)
|
||||
if nodesResponse.Code != http.StatusOK {
|
||||
t.Fatalf("node list status = %d: %s", nodesResponse.Code, nodesResponse.Body.String())
|
||||
}
|
||||
var nodesBody struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
decodeBody(t, nodesResponse, &nodesBody)
|
||||
statuses := map[string]NodeStatus{}
|
||||
for _, node := range nodesBody.Nodes {
|
||||
statuses[node.NodeID] = node.Status
|
||||
}
|
||||
if statuses["client-a"] != NodeOffline || statuses["client-b"] != NodeOnline {
|
||||
t.Fatalf("unexpected client statuses: %+v", statuses)
|
||||
}
|
||||
|
||||
getTask := func(taskID string) Task {
|
||||
response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks/"+taskID, webAuth, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("get task %s status = %d: %s", taskID, response.Code, response.Body.String())
|
||||
}
|
||||
var task Task
|
||||
decodeBody(t, response, &task)
|
||||
return task
|
||||
}
|
||||
if task := getTask(taskA.TaskID); task.Status != TaskWaitingForClient {
|
||||
t.Fatalf("client A task status = %s, want %s", task.Status, TaskWaitingForClient)
|
||||
}
|
||||
if task := getTask(taskB.TaskID); task.Status != TaskPending {
|
||||
t.Fatalf("client B task status = %s, want %s", task.Status, TaskPending)
|
||||
}
|
||||
|
||||
bPoll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-b/tasks?account_id=account-a", "Bearer secret-b", nil)
|
||||
var bBatch TaskBatch
|
||||
decodeBody(t, bPoll, &bBatch)
|
||||
if len(bBatch.Tasks) != 1 || bBatch.Tasks[0].TaskID != taskB.TaskID {
|
||||
t.Fatalf("client B received the wrong tasks: %+v", bBatch.Tasks)
|
||||
}
|
||||
bLease := bBatch.Tasks[0]
|
||||
bAck := TaskAck{TaskID: bLease.TaskID, AccountID: bLease.AccountID, LeaseGeneration: bLease.LeaseGeneration}
|
||||
for _, phase := range []string{"ack", "start"} {
|
||||
response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/tasks/"+bLease.TaskID+"/"+phase, "Bearer secret-b", bAck)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("client B %s status = %d: %s", phase, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
result := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/tasks/"+bLease.TaskID+"/result", "Bearer secret-b", TaskResult{
|
||||
TaskID: bLease.TaskID, AccountID: bLease.AccountID, LeaseGeneration: bLease.LeaseGeneration,
|
||||
Status: TaskSucceeded, HasSideEffect: true, CorrelationID: "client-b-result",
|
||||
})
|
||||
if result.Code != http.StatusOK {
|
||||
t.Fatalf("client B result status = %d: %s", result.Code, result.Body.String())
|
||||
}
|
||||
bEvent := MessageEvent{NodeID: "client-b", AccountID: "account-a", ChatID: "client-b-chat", ChatType: ChatPrivate,
|
||||
EventSeq: 1, EventType: "message", OccurredAt: time.Now().UTC(), Content: "client-b-content", ConfigVersion: 1, AuthorizationVersion: 1, Authorized: true}
|
||||
bEventResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-b/events", "Bearer secret-b", bEvent)
|
||||
if bEventResponse.Code != http.StatusAccepted {
|
||||
t.Fatalf("client B event status = %d: %s", bEventResponse.Code, bEventResponse.Body.String())
|
||||
}
|
||||
bEvents := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/events?node_id=client-b", webAuth, nil)
|
||||
var bEventList struct {
|
||||
Events []StoredEvent `json:"events"`
|
||||
}
|
||||
decodeBody(t, bEvents, &bEventList)
|
||||
if len(bEventList.Events) != 1 || bEventList.Events[0].NodeID != "client-b" {
|
||||
t.Fatalf("client B event isolation failed: %+v", bEventList.Events)
|
||||
}
|
||||
aPoll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-a/tasks?account_id=account-a", "Bearer secret-a", nil)
|
||||
var aBatch TaskBatch
|
||||
decodeBody(t, aPoll, &aBatch)
|
||||
if len(aBatch.Tasks) != 0 {
|
||||
t.Fatalf("offline client A received tasks: %+v", aBatch.Tasks)
|
||||
}
|
||||
|
||||
register("client-a", "secret-a", "connection-a-2")
|
||||
staleHeartbeat := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-a/heartbeat", "Bearer secret-a", Heartbeat{
|
||||
NodeID: "client-a", ConnectionID: "connection-a-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion,
|
||||
NodeStatus: NodeOnline, CorrelationID: "stale-heartbeat",
|
||||
})
|
||||
if staleHeartbeat.Code != http.StatusConflict {
|
||||
t.Fatalf("stale heartbeat status = %d: %s", staleHeartbeat.Code, staleHeartbeat.Body.String())
|
||||
}
|
||||
freshHeartbeat := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/client-a/heartbeat", "Bearer secret-a", Heartbeat{
|
||||
NodeID: "client-a", ConnectionID: "connection-a-2", AgentVersion: "test", ProtocolVersion: ProtocolVersion,
|
||||
NodeStatus: NodeOnline, CorrelationID: "fresh-heartbeat",
|
||||
})
|
||||
if freshHeartbeat.Code != http.StatusOK {
|
||||
t.Fatalf("fresh heartbeat status = %d: %s", freshHeartbeat.Code, freshHeartbeat.Body.String())
|
||||
}
|
||||
|
||||
resume := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks/"+taskA.TaskID+"/resume", webAuth, nil)
|
||||
var resumed Task
|
||||
decodeBody(t, resume, &resumed)
|
||||
if resume.Code != http.StatusOK || resumed.Status != TaskPending {
|
||||
t.Fatalf("resume status = %d task=%+v", resume.Code, resumed)
|
||||
}
|
||||
aPoll = doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/client-a/tasks?account_id=account-a", "Bearer secret-a", nil)
|
||||
aBatch = TaskBatch{}
|
||||
decodeBody(t, aPoll, &aBatch)
|
||||
if len(aBatch.Tasks) != 1 || aBatch.Tasks[0].TaskID != taskA.TaskID {
|
||||
t.Fatalf("resumed client A received the wrong tasks: %+v", aBatch.Tasks)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskPending TaskStatus = "Pending"
|
||||
TaskWaitingForClient TaskStatus = "WaitingForClient"
|
||||
TaskAccepted TaskStatus = "Accepted"
|
||||
TaskRunning TaskStatus = "Running"
|
||||
TaskSucceeded TaskStatus = "Succeeded"
|
||||
@@ -42,6 +43,7 @@ const (
|
||||
|
||||
type NodeRegistration struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -59,6 +61,7 @@ type AccountSummary struct {
|
||||
|
||||
type Heartbeat struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
NodeStatus NodeStatus `json:"node_status"`
|
||||
@@ -169,6 +172,7 @@ type AuditEntry struct {
|
||||
|
||||
type Node struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -327,6 +331,7 @@ type PersistedAIConfig struct {
|
||||
|
||||
type NodeResponse struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Status NodeStatus `json:"status"`
|
||||
Authenticated bool `json:"authenticated,omitempty"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
|
||||
+94
-6
@@ -328,13 +328,17 @@ func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlatio
|
||||
if request.NodeID != nodeID {
|
||||
return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."}
|
||||
}
|
||||
if !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) {
|
||||
if (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) {
|
||||
return requestError{status: http.StatusBadRequest, code: "InvalidRegistration", message: "Node registration is invalid."}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.store.Mutate(func(state *PersistedState) error {
|
||||
node := state.Nodes[nodeID]
|
||||
if node.NodeID != "" && node.ConnectionID != request.ConnectionID {
|
||||
quarantineNodeTasks(state, nodeID, now)
|
||||
}
|
||||
node.NodeID = nodeID
|
||||
node.ConnectionID = request.ConnectionID
|
||||
node.AgentVersion = request.AgentVersion
|
||||
node.ProtocolVersion = request.ProtocolVersion
|
||||
node.Capabilities = append([]string(nil), request.Capabilities...)
|
||||
@@ -349,7 +353,7 @@ func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlatio
|
||||
}); err != nil {
|
||||
return requestError{status: http.StatusInternalServerError, code: "PersistenceFailed", message: "The control plane could not persist the node registration."}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID})
|
||||
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -406,6 +410,8 @@ func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
if nodeChanged {
|
||||
quarantineNodeTasks(state, nodeID, now)
|
||||
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.offline", nodeID, "", "success", now)
|
||||
state.Nodes[nodeID] = node
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
@@ -440,7 +446,7 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
|
||||
if err := decodeJSON(r, &request, 32*1024); err != nil {
|
||||
return err
|
||||
}
|
||||
if request.NodeID != nodeID || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 {
|
||||
if request.NodeID != nodeID || (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 {
|
||||
return requestError{status: http.StatusBadRequest, code: "InvalidHeartbeat", message: "Heartbeat is invalid."}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -449,6 +455,9 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
|
||||
if node.NodeID == "" {
|
||||
return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before sending a heartbeat."}
|
||||
}
|
||||
if node.ConnectionID != request.ConnectionID {
|
||||
return requestError{status: http.StatusConflict, code: "StaleConnection", message: "The heartbeat belongs to an older Client connection."}
|
||||
}
|
||||
node.AgentVersion = request.AgentVersion
|
||||
node.ProtocolVersion = request.ProtocolVersion
|
||||
node.Status = request.NodeStatus
|
||||
@@ -467,7 +476,7 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, corre
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID})
|
||||
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -951,6 +960,9 @@ func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID
|
||||
if len(parts) == 4 && parts[3] == "cancel" && r.Method == http.MethodPost {
|
||||
return s.cancelTask(w, parts[2], username, correlationID)
|
||||
}
|
||||
if len(parts) == 4 && parts[3] == "resume" && r.Method == http.MethodPost {
|
||||
return s.resumeTask(w, parts[2], username, correlationID)
|
||||
}
|
||||
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
|
||||
}
|
||||
|
||||
@@ -993,10 +1005,14 @@ func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmiss
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
status := TaskPending
|
||||
if !nodeAvailable(node, now, s.config.HeartbeatTimeout) {
|
||||
status = TaskWaitingForClient
|
||||
}
|
||||
task := Task{
|
||||
TaskID: randomID(), NodeID: request.NodeID, AccountID: request.AccountID, Kind: request.Kind,
|
||||
IdempotencyKey: request.IdempotencyKey, Payload: append(jsonRaw(nil), request.Payload...), NotAfter: request.NotAfter,
|
||||
Status: TaskPending, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID,
|
||||
Status: status, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID,
|
||||
}
|
||||
state.Tasks[task.TaskID] = task
|
||||
state.Audit = appendAudit(state.Audit, "user:"+username, "task.create", task.TaskID, correlationID, "success", now)
|
||||
@@ -1068,7 +1084,7 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
|
||||
if task.CancelRequestedAt == nil {
|
||||
task.CancelRequestedAt = &now
|
||||
task.StateVersion++
|
||||
if task.Status == TaskPending && !leaseActive(task, now) {
|
||||
if (task.Status == TaskPending || task.Status == TaskWaitingForClient) && !leaseActive(task, now) {
|
||||
task.Status = TaskCancelled
|
||||
task.LeaseOwner = ""
|
||||
task.LeaseExpiresAt = nil
|
||||
@@ -1086,6 +1102,49 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) resumeTask(w http.ResponseWriter, taskID, username, correlationID string) error {
|
||||
var task Task
|
||||
err := s.store.Mutate(func(state *PersistedState) error {
|
||||
value, ok := state.Tasks[taskID]
|
||||
if !ok {
|
||||
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
|
||||
}
|
||||
task = value
|
||||
if terminal(task.Status) {
|
||||
return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task cannot be resumed after it reached a terminal state."}
|
||||
}
|
||||
if task.Status != TaskWaitingForClient {
|
||||
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "Only tasks waiting for a Client can be resumed."}
|
||||
}
|
||||
if task.CancelRequestedAt != nil {
|
||||
return requestError{status: http.StatusConflict, code: "CancelRequested", message: "The task was cancelled and cannot be resumed."}
|
||||
}
|
||||
node, exists := state.Nodes[task.NodeID]
|
||||
now := time.Now().UTC()
|
||||
if !exists || !nodeAvailable(node, now, s.config.HeartbeatTimeout) || !nodeHasAccount(node, task.AccountID) {
|
||||
return requestError{status: http.StatusConflict, code: "ClientNotReady", message: "The target Client is not connected with the requested account."}
|
||||
}
|
||||
if task.NotAfter != nil && !now.Before(*task.NotAfter) {
|
||||
task.Status = TaskExpired
|
||||
task.StateVersion++
|
||||
task.UpdatedAt = now
|
||||
} else {
|
||||
task.Status = TaskPending
|
||||
task.StateVersion++
|
||||
task.UpdatedAt = now
|
||||
task.LastCorrelationID = correlationID
|
||||
}
|
||||
state.Tasks[taskID] = task
|
||||
state.Audit = appendAudit(state.Audit, "user:"+username, "task.resume", taskID, correlationID, "success", now)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) eventRoute(w http.ResponseWriter, r *http.Request, _ string) error {
|
||||
if r.Method != http.MethodGet {
|
||||
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
|
||||
@@ -1332,6 +1391,31 @@ func markUnconfirmed(task *Task, reason string, now time.Time) {
|
||||
task.Result = &TaskResult{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, Status: TaskResultUnconfirmed, ErrorCode: reason, Message: "The control plane could not prove that the previous executor stopped before the lease expired.", CorrelationID: task.LastCorrelationID}
|
||||
}
|
||||
|
||||
func quarantineNodeTasks(state *PersistedState, nodeID string, now time.Time) {
|
||||
for taskID, value := range state.Tasks {
|
||||
if value.NodeID != nodeID || terminal(value.Status) {
|
||||
continue
|
||||
}
|
||||
task := value
|
||||
switch task.Status {
|
||||
case TaskPending, TaskWaitingForClient:
|
||||
if task.Status == TaskWaitingForClient && task.LeaseOwner == "" && task.LeaseExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
task.Status = TaskWaitingForClient
|
||||
task.StateVersion++
|
||||
task.UpdatedAt = now
|
||||
task.LeaseOwner = ""
|
||||
task.LeaseExpiresAt = nil
|
||||
case TaskAccepted, TaskRunning:
|
||||
markUnconfirmed(&task, "ClientDisconnected", now)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
state.Tasks[taskID] = task
|
||||
}
|
||||
}
|
||||
|
||||
func validCapabilities(capabilities []string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if !validIdentifier(capability, 80) {
|
||||
@@ -1373,6 +1457,10 @@ func nodeHasAccount(node Node, accountID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func nodeAvailable(node Node, now time.Time, heartbeatTimeout time.Duration) bool {
|
||||
return node.Status != NodeOffline && node.LastHeartbeatAt != nil && now.Sub(*node.LastHeartbeatAt) <= heartbeatTimeout
|
||||
}
|
||||
|
||||
func validSendTextPayload(payload jsonRaw) bool {
|
||||
if len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) {
|
||||
return false
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-10547
File diff suppressed because it is too large
Load Diff
+40
File diff suppressed because one or more lines are too long
Vendored
+5
-4
@@ -3,10 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0d1829" />
|
||||
<title>WxAgent 控制面</title>
|
||||
<script type="module" crossorigin src="/assets/index-Y-WkDYNQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ub8kdaer.css">
|
||||
<meta name="theme-color" content="#f6f7fb" />
|
||||
<meta name="description" content="WxAgent 普通用户工作台" />
|
||||
<title>WxAgent 工作台</title>
|
||||
<script type="module" crossorigin src="/assets/index-imGrBp_j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BkrtfWai.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0d1829" />
|
||||
<title>WxAgent 控制面</title>
|
||||
<meta name="theme-color" content="#f6f7fb" />
|
||||
<meta name="description" content="WxAgent 普通用户工作台" />
|
||||
<title>WxAgent 工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+364
-1228
File diff suppressed because it is too large
Load Diff
+1251
-795
File diff suppressed because it is too large
Load Diff
+31
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
状态以 `GET /api/v1/capabilities` 为准;`implemented` 不代表 `validated`,数据库指纹也不代表当前 UI 账号绑定。
|
||||
|
||||
> 多 Client 约束:平台可同时连接多个 Desktop Agent/Client;任务、事件、账号绑定和 UI 业务上下文按 `client_id`(内部可兼容 `node_id`)隔离。单个 Client 掉线只影响其自身任务和事件流,不得让其它 Client 进入离线或停止操作。
|
||||
|
||||
| 操作 | REST | MCP | 当前状态 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 服务/微信诊断 | `GET /api/v1/status`、`/api/v1/diagnostics` | `agent_status`、`agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 |
|
||||
@@ -13,10 +15,38 @@
|
||||
| 数据库消息/合并记录 | `GET /api/v1/db/messages`、`/api/v1/db/merged` | `db_messages`、`db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL |
|
||||
| 任务查询/取消 | `GET /api/v1/operations`、`/api/v1/operations/{id}`、`POST /api/v1/operations/{id}/cancel` | `operations_list`、`operation_get`、`operation_cancel` | Ready | 按 principal 隔离;`accountId` 仅为调用方视图筛选,不是账号授权;取消不撤销已发生副作用 |
|
||||
| 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 |
|
||||
| 单目标文本发送 | `POST /api/v1/operations`,`kind=send-text` | `operation_submit` | Implemented but disabled | 已接入任务/幂等/写后确认契约;`send-text` 保持 disabled,须完成账号绑定、目标唯一性和 Windows 真机验收后才能开放 |
|
||||
| 单目标文本发送 | `POST /api/v1/operations`,`kind=send-text` | `operation_submit` | Implemented(仅验证模式开放) | 已完成单 Client 账号绑定、目标唯一性、确认、幂等和 Windows 真机写后确认;默认部署仍只读,Web UI 直接提交继续门控 |
|
||||
| 多目标文本群发 | `POST /api/v1/operations`,`kind=broadcast-text` | `operation_submit` | Implemented(仅验证模式开放) | 冻结 1–20 个可见且唯一的会话 automation ID,串行逐项发送、记录结果、按错误停止并支持幂等重放;真实单 Client 最终已完成两个白名单目标均成功的群发验收,并保留失败停止证据 |
|
||||
| 文件/卡片发送、联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 |
|
||||
| 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 |
|
||||
|
||||
## 本轮普通用户工作台状态(2026-09-19)
|
||||
|
||||
| 范围 | 当前状态 | 验证边界 |
|
||||
| --- | --- | --- |
|
||||
| 消息首页、会话和消息正文 | 已实现/只读已验证 | 通过真实控制面读取任务和双 Client 联调;仅显示当前 Client 的数据 |
|
||||
| 通讯录/群列表 | 已实现/只读已验证 | 通过真实控制面读取任务联调;添加好友保持禁用 |
|
||||
| 任务中心 | 已实现/只读已验证 | 展示 Client 归属、状态和结果;不提供未经验收的写操作重试 |
|
||||
| 多 Client 选择与隔离 | 已实现/协议及浏览器已验证 | `client_id`/`node_id`、连接代次、任务和事件按 Client 隔离;掉线任务进入等待/待核对,不自动跨 Client 改投 |
|
||||
| 发送、群发、添加好友 | 发送/群发已具备验证模式入口;添加好友禁用 | 单 Client 白名单对象已完成发送与 `broadcast-text` 多目标逐项/停止/幂等验证;默认部署和 Web UI 直接写入口仍安全门控,添加好友不在本轮 |
|
||||
|
||||
详细步骤、自动检查和 Windows 证据见 [WebUI-MCP-2026-09-19 验收记录](validation/WebUI-MCP-2026-09-19.md)。
|
||||
|
||||
## 单 Client 生产验证状态(2026-09-19)
|
||||
|
||||
| 范围 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `.mcp.json` Windows 直连、真实 Session 1、账号绑定 | 已验证 | 使用真实微信 UI 和本地 Client 服务;未使用 Session 0 或 fake Client |
|
||||
| 会话、消息、联系人、群只读 | 已验证 | 真实账号 Rogee;会话/消息/白名单联系人及群查询通过 |
|
||||
| 单消息发送 | 已验证(受控) | 文件传输助手通过 Windows MCP 发送并在 UIA 中确认;服务 send-text 保留确认、幂等和目标校验 |
|
||||
| 普通群消息 | 已验证(受控) | 吉祥三宝通过可见会话 automation ID 发送并返回 `Succeeded`;群预览完成写后确认 |
|
||||
| 多目标文本群发 | 已验证(受控) | 最终真实 `broadcast-text` 冻结“文件传输助手”和“吉祥三宝”两个目标,逐项均返回 `Succeeded`,并确认幂等重放不创建第二次群发;此前失败项按 `stopOnError` 停止的记录也保留。 |
|
||||
| `@所有人` / `group-at-all` | 禁用 | 当前没有注册独立操作,不能宣称已验证 |
|
||||
| 添加好友 | 禁用 | 本轮明确排除,未进行真实联系人变更 |
|
||||
| 多 Client / 跨 Client 故障恢复 | 未纳入 | 本轮只验收单 Client |
|
||||
|
||||
详细操作 ID、失败结果、版本差异和剩余风险见 [单 Client 生产验证记录](validation/WebUI-MCP-single-client-2026-09-19.md)。
|
||||
|
||||
## 运行边界
|
||||
|
||||
- 可通过 `WxAgent.Host serve --config <file>` 启动;安装版使用托盘程序,服务设置和唯一 Token 均在同一个系统设置窗口完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。
|
||||
|
||||
+34
-1
@@ -31,7 +31,7 @@
|
||||
### 1.2 首版范围
|
||||
|
||||
- 本机或外部浏览器、HTTP 客户端与 MCP 客户端访问同一 Host;外部访问必须认证,HTTP 与 MCP 共用 Token、身份及权限。默认仍仅监听回环地址,外部 HTTP 监听须显式启用;不要求 HTTPS。
|
||||
- 一个交互式 Windows 用户会话、一个自动化控制目标;多数据库账号可显式选择,但不承诺多微信实例并行控制。
|
||||
- 支持多个 Client/ Desktop Agent 同时连接控制平台;每个 Client 绑定一个交互式 Windows 用户会话和一个自动化控制目标。多数据库账号可显式选择,但不承诺单个 Client 多微信实例并行控制。
|
||||
- 完成诊断、会话、基础消息、受控文件、只读联系人/群成员、监听及任务中心。
|
||||
- 管理和朋友圈等风险功能按能力状态逐项接入,不默认开放未经真机验收的写操作。
|
||||
- 支持经 Token 认证的外部 HTTP 入口;明文传输不具备保密性,不作为安全公网部署方案。暂不做多租户、Windows Service、自动升级、批量营销或绕过已有确认门禁的破坏性操作。
|
||||
@@ -87,6 +87,39 @@ node-agent/WxAgent.Core/ 必要的跨平台契约、校验和任务状
|
||||
- 监听器只在短快照采集时占用 UI 调度,不持锁等待整个监听生命周期;不同目标冲突时排队或明确拒绝,不偷偷切换。
|
||||
- 关闭服务时停止接单、取消未执行任务、保存已有监听 checkpoint;重启不自动重放写任务。
|
||||
|
||||
### 2.3 多 Client 连接与故障隔离
|
||||
|
||||
本节中的 **Client** 指连接控制平台的 Desktop Agent/微信运行端,对应现有 `node_id`;浏览器、HTTP 和 MCP 是调用方。平台的 Client 注册表、心跳、任务路由和事件流必须按 Client 隔离,不能把 Client 状态做成全局单例。
|
||||
|
||||
#### 设计约束
|
||||
|
||||
- 每个 Client 使用稳定 `client_id`(内部可兼容 `node_id`)和独立连接租约/代次;重连不得把旧连接的迟到心跳写回新连接。
|
||||
- 任务、消息、事件和绑定的作用域至少为 `(client_id, account_id, target_id)`;相同账号或会话名称在不同 Client 上也不得自动合并。
|
||||
- 控制面可以共享任务存储和认证,但每个 Client 必须有独立执行队列。一个 Client 的 UI 自动化占用只影响该 Client,不得阻塞其它 Client 的队列。
|
||||
- Client 掉线只更新该 Client 的状态并停止投递到该 Client;其它 Client 继续接收任务、事件和心跳。只有全部 Client 都不可用时,平台才报告“无可用 Client”。
|
||||
- 目标 Client 掉线时,运行中的只读任务失败并可重新发起;可能已经产生副作用的写任务进入 `Unconfirmed`,禁止自动重试或改投其它 Client。
|
||||
- 尚未执行的写任务进入 `WaitingForClient`/“等待 Client”,Client 重连后不自动重放;用户必须查看目标和内容后显式恢复或新建任务。其它 Client 的任务不受影响。
|
||||
- 每个 Client 的 SSE/订阅独立关闭和恢复;一个订阅断开不得关闭控制面的其它订阅。事件游标必须带 Client 作用域。
|
||||
- UI 的 Client 切换器只切换业务上下文,不清空其它 Client 的任务历史;当前 Client 不可用时显示局部错误和切换入口,而不是全局错误页。
|
||||
|
||||
#### 最小契约变更
|
||||
|
||||
| 位置 | 要求 |
|
||||
| --- | --- |
|
||||
| Client 注册/心跳 | `client_id`、连接代次、最后心跳、微信状态和能力独立记录;迟到连接不能覆盖新连接 |
|
||||
| 任务 | 保留 `node_id` 兼容字段,同时在对外文案使用 Client;任务创建、查询、取消均校验目标 Client |
|
||||
| 事件/SSE | 订阅、游标、缓存和断线恢复按 Client 分区;禁止跨 Client 复用游标 |
|
||||
| 前端状态 | `selectedClientId` 与 `globalAvailability` 分离;单 Client 离线只影响选中上下文 |
|
||||
| 调度 | 共用持久化存储可以保留,但执行队列、门禁、超时和取消状态按 Client 管理 |
|
||||
|
||||
#### 验收用例
|
||||
|
||||
1. 同时连接 Client A、Client B;A 掉线后,B 仍可查看会话、接收事件并提交任务。
|
||||
2. A 的任务进入 `WaitingForClient` 或 `Unconfirmed`,B 的任务继续执行;A 重连不自动重放写任务。
|
||||
3. 在 A/B 之间切换时,会话列表、消息、任务状态和错误不会串用;同名账号/会话仍按 Client 分开。
|
||||
4. A 的事件流断开或游标过期时,B 的事件流不受影响;平台只有在 A、B 都不可用时才显示全局离线。
|
||||
5. Client 连接顺序变化、旧心跳迟到、重复注册和重连都不会覆盖其它 Client 的在线状态。
|
||||
|
||||
## 3. 功能、页面与服务映射
|
||||
|
||||
以下 REST 和 MCP 名称为**拟新增契约**,不是现有接口。U0 输出最终逐项操作清单,包括真实方法签名、输入、输出、风险和证据。
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
# Web UI 面向普通用户重构落地指导
|
||||
|
||||
> 状态:待实施指导文档,不代表功能已开放或通过验收。
|
||||
> 状态:实现中;文档描述的写操作仍不代表已开放或通过验收。
|
||||
> 产品定位:普通用户连接 Agent 后,可以查看和回复消息、选择对象群发、添加好友、查看执行结果,无需理解 CLI、MCP、UIA 或数据库。
|
||||
> 本文依据当前工作区源码整理;已有未提交修改,不视为已发布版本。本次仅交付文档,不修改运行代码、不开放写权限、不执行微信写操作。
|
||||
> 本文依据当前工作区源码整理;当前工作树包含 Web UI、Client 隔离和协议实现修改,不视为已发布版本。消息/通讯录/任务只读闭环按任务队列接入;Web UI 的发送、群发和加好友提交仍保持安全禁用,后端 `send-text`/`broadcast-text` 仅在明确授权的验证模式下开放。
|
||||
>
|
||||
> 待实施决策:认证成功的 Token 可获取 Agent 当前登录账号的数据,不按 `AccountIds` 做账号级授权隔离;调用方负责账号、页面和业务视图隔离。Agent 仍保留能力权限、账号与窗口绑定一致性、目标核验,并补齐 CSRF/Origin 等服务端安全校验。好友申请冷却明确按“发送账号+目标”计算,为 4 小时。这些是目标行为,不代表当前源码已实现。
|
||||
|
||||
## 0. 已确认的产品与交互基线
|
||||
|
||||
### 0.1 产品模式
|
||||
|
||||
本次改造采用 **Operate(工作台)** 模式,不再把后端接口、节点心跳和诊断指标作为普通用户首页。普通用户默认进入“消息”,目标是完成“确认可用 → 找到会话 → 阅读 → 回复”,而不是理解 Agent、MCP、UIA 或任务队列。
|
||||
|
||||
主导航固定为:
|
||||
|
||||
```text
|
||||
消息(默认)
|
||||
群发
|
||||
通讯录
|
||||
任务
|
||||
设置 → 连接与账号 / 高级诊断
|
||||
```
|
||||
|
||||
群发采用“选择对象 → 编辑内容 → 预览确认 → 执行结果”四步流程;添加好友采用“搜索 → 确认候选 → 填写验证信息 → 提交 → 查看结果”流程。技术字段仅在高级诊断中展示。
|
||||
|
||||
### 0.2 Client 定义
|
||||
|
||||
本文中的 **Client** 指连接控制平台的 Desktop Agent/微信运行端,对应现有控制面中的 `node_id`;浏览器、HTTP 和 MCP 属于调用方,不与 Client 混用。每个 Client 可以绑定自己的微信账号/窗口,但单个 Client 仍只承诺一个交互式 Windows 会话和一个自动化控制目标。
|
||||
|
||||
### 0.3 多 Client 隔离是硬约束
|
||||
|
||||
平台必须支持多个 Client 同时在线,且一个 Client 掉线不能影响其它 Client:
|
||||
|
||||
- Client 使用稳定的 `client_id`(内部可兼容现有 `node_id`)和独立心跳/租约;不得用全局单例表示在线状态。
|
||||
- 连接、账号/窗口绑定、消息会话、事件流、任务路由和执行队列均以 Client 为作用域;业务上下文至少包含 `(client_id, account_id, target_id)`。
|
||||
- UI 顶部提供 Client 选择器。消息、通讯录和写操作必须先选定 Client;“全部 Client”只用于查看健康状态或任务汇总,不能把不同 Client 的同名会话合并。
|
||||
- 顶部显示当前 Client 的状态。当前 Client 掉线时只禁用当前上下文并提供切换入口;只有所有 Client 都不可用时才显示全局不可用。
|
||||
- 运行中的只读任务可失败并允许重新发起;运行中的写任务若无法证明未产生副作用,必须标记“结果待核对”,禁止自动重试或改投其它 Client。
|
||||
- 已排队但尚未执行的写任务在 Client 掉线后进入“等待 Client/需重新确认”,Client 重连不自动重放;其它 Client 的任务继续排队或执行。
|
||||
- 一个 Client 的 SSE/事件断开只关闭该 Client 的订阅,不关闭平台或其它 Client 的事件流。
|
||||
- 任务、事件、错误和审计记录都显示所属 Client,避免用户把乙 Client 的状态误认为甲 Client 的结果。
|
||||
|
||||
### 0.4 本次交付边界
|
||||
|
||||
本轮按本文件落地 Client 选择与隔离、普通用户工作台壳层以及消息/通讯录/任务的只读任务队列接入。Web UI 的发送、群发和添加好友只保留清晰的禁用状态;后端发送/群发能力仅在明确授权的验证模式下使用,默认部署仍需保持只读。
|
||||
|
||||
## 1. 改造结论与范围
|
||||
|
||||
当前 UI 是开发者诊断控制台,目标 UI 应是微信业务工作台。改造不是换配色、加卡片,而是把页面组织方式从“后端有什么接口”改成“用户要完成什么事情”。
|
||||
@@ -22,7 +61,7 @@
|
||||
|
||||
### 1.2 本轮不做
|
||||
|
||||
- 不引入 CRM、团队客服分配、自动回复机器人、营销自动化、定时发送、多租户、云端中转或多 Agent 并行控制。
|
||||
- 不引入 CRM、团队客服分配、自动回复机器人、营销自动化、定时发送、多租户或云端中转;支持多个 Client 同时连接控制平台,但不承诺单个 Client 多微信实例并行控制。
|
||||
- 不承诺读取全部会话、全部历史或离线消息必达;不实现“下一个未读会话”。
|
||||
- 不顺带开放朋友圈、删除好友、移出群成员等高风险操作。
|
||||
- 不引入新前端框架、通用工作流引擎、独立消息队列或 Windows Service。
|
||||
@@ -48,7 +87,7 @@
|
||||
| 消息 | 列出可见会话和当前可见消息;当前前端没有完整会话点击/回复流程 | 会话导航、正文权限引导、聊天布局、发送接口和结果验证 |
|
||||
| 联系人/群 | 已有数据库只读分页查询接口;前端加载首批联系人 | 搜索、加载更多、选中名单和稳定目标解析 |
|
||||
| 导航/历史 | 已有部分路由;导航待验收、数据库消息能力存在禁用项 | 不得把“接口存在”当成“页面可直接使用” |
|
||||
| 单条发送 | 服务层尚无文本发送入口,功能矩阵标记 Disabled | 接入现有 Windows 业务方法、权限/唯一目标/写后确认、真机验收 |
|
||||
| 单条发送 | 服务层已有 `send-text` 验证模式入口;Web UI 直接提交仍门控 | 继续保留账号/权限/唯一目标/写后确认,并以单 Client 真机记录作为受控证据 |
|
||||
| 群发/加好友 | 尚无 Web 业务闭环 | 有界批量任务、单人好友申请接口及端到端验收 |
|
||||
| 任务 | 持久化队列、按 ID 查询/取消、幂等与重启状态处理 | 缺少任务列表、业务结果字段、批量逐项结果;不是只改前端就能补齐 |
|
||||
| 事件 | 服务端已有 SSE,默认关闭,需显式启用并验收;当前前端主要手动或每 15 秒刷新 | 前端事件接入、断线恢复与缺口提示,不能宣称完整实时收件箱 |
|
||||
@@ -240,8 +279,8 @@ Agent 自己的 `operations.sqlite` 可以保存业务任务元数据;这不
|
||||
| 建议入口 | 用途 | 必须校验/返回 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/operations` | 当前身份的任务列表 | 按当前登录 principal 过滤;可按 `accountId` 筛选但不做 Token 账号授权拒绝;有界分页、摘要,不泄漏其他 principal 的任务 |
|
||||
| `POST /api/v1/operations`,`kind=send-text` | 单目标文本发送 | **基础已接入但 capability disabled**;accountId、AutomationId 目标引用、text、idempotencyKey、confirmed;受理返回任务 ID,不直接宣称成功;开放前须完成 Windows 真机验收 |
|
||||
| 同入口,`kind=broadcast-text` | 提交有界群发 | 冻结目标列表、去重、条数/长度限制、显式确认、逐项幂等与批次详情 |
|
||||
| `POST /api/v1/operations`,`kind=send-text` | 单目标文本发送 | **验证模式入口已接入,默认部署和 Web UI 仍门控**;accountId、AutomationId 目标引用、text、idempotencyKey、confirmed;受理返回任务 ID,只有写后确认才标成功;单 Client 真机证据见验收记录 |
|
||||
| 同入口,`kind=broadcast-text` | 提交有界群发 | **验证模式入口已接入,默认部署和 Web UI 仍门控**;冻结目标列表、去重、条数/长度限制、显式确认、串行逐项结果、停止和幂等详情 |
|
||||
| `POST /api/v1/friend-search` | 好友候选预览 | Token/能力、绑定状态、超时、候选归属当前账号;不发送申请 |
|
||||
| `POST /api/v1/operations`,`kind=friend-request` | 单人好友申请 | 当前账号绑定的有效候选引用、验证信息、显式确认、幂等键;按 §4.4 原子接纳并执行,发送账号+目标稳定身份冷却 4 小时,返回任务或冷却 `retryAt` |
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Web UI / MCP 验收记录(2026-09-19)
|
||||
|
||||
## 范围
|
||||
|
||||
本记录验证普通用户 Web 工作台、多 Client 隔离,以及消息、通讯录、任务的只读任务队列闭环;它是只读基线,不覆盖随后单 Client 验证模式下的写操作。Web UI 的发送、群发、添加好友提交仍保持禁用;真实 Client API 的 `send-text`/`broadcast-text` 受控证据见[单 Client 生产验证记录](WebUI-MCP-single-client-2026-09-19.md)。
|
||||
|
||||
## Linux 自动检查
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `cd control-plane && go test ./...` | 通过 |
|
||||
| `cd control-plane/web && npm run build` | 通过;生成 `index-BkrtfWai.css`、`index-_unPe9bQ.js` |
|
||||
| `dotnet test tests/node-agent/WxAgent.Core.Tests -c Release --no-restore` | 通过;168 tests,0 failed,0 skipped |
|
||||
| `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore` | 通过;0 warnings,0 errors |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
## Web UI 只读联调
|
||||
|
||||
- 控制面:`http://127.0.0.1:8090/`;测试身份为本地测试管理员。
|
||||
- 使用两个隔离的 fake Client(`client-a` / `client-b`)持续发送 heartbeat,并只处理真实控制面任务队列中的读取任务;fake Client 不是产品能力,也不计入 Windows 真机通过数。
|
||||
- 浏览器实测消息页默认打开;Client 切换后会清空旧会话上下文并丢弃过期响应。
|
||||
- Client A 的会话、消息正文、通讯录/群和任务记录均可通过队列读取;切换 Client 后不串用数据。
|
||||
- 发送按钮、群发提交、添加好友按钮均保持 `disabled`,并显示未完成能力/真机验收原因。
|
||||
- 桌面及 390px 窄屏布局通过浏览器检查,无横向溢出;加载、空列表、离线、错误和权限提示由 UI 提供明确状态。
|
||||
|
||||
## Windows 真机检查
|
||||
|
||||
发布包为 `WxAgent.Host` self-contained `win-x64` single-file,上传至 `C:\Users\Rogee\wx-agent`,在已登录的交互式 Windows 用户会话(Session 1)中执行:
|
||||
|
||||
- 微信版本:`4.1.13.65`。
|
||||
- `doctor`:通过;`WindowFound=true`,`MainView/session_list/chat_message_page/chat_message_list/chat_input_field/tool_bar_accessible` 均为 `true`,`Errors=[]`,`InputDesktopAvailable=true`。
|
||||
- `inspect-ui --output artifacts/ui-tree.json`:通过;164 个脱敏节点,`sanitized=true`。
|
||||
- `smoke`:通过;`scope=M1`,目标为白名单“文件传输助手”,`sendSkipped=false`,`confirmed=true`。该命令是既有 CLI smoke 的受控写验证,不代表 Web UI 已开放写操作。
|
||||
|
||||
## 未完成/保留限制
|
||||
|
||||
- Web UI 的消息发送、群发、添加好友提交继续禁用;后端验证模式的 `send-text` 与 `broadcast-text` 已有真实单 Client 证据,详见单 Client 记录,默认部署仍保持只读。
|
||||
- fake Client 只验证控制面任务路由和 Client 隔离,不替代真实 Desktop Agent 的只读能力验收。
|
||||
- 未进行外部网络部署、MCP 客户端兼容性和长时事件流验收;不改变功能矩阵中对应状态。
|
||||
@@ -0,0 +1,69 @@
|
||||
# Web UI / MCP 单 Client 生产验证记录(2026-09-19)
|
||||
|
||||
## 验证范围
|
||||
|
||||
通过项目 `.mcp.json` 连接 Windows MCP,在已登录、未锁定的 Session 1 中验证一个真实微信 Client(账号 Rogee)。本轮不验证添加好友和多 Client;只使用白名单对象:文件传输助手、Hao 豪、吉祥三宝、消息测试专用群组。
|
||||
|
||||
## 连接与放行
|
||||
|
||||
- `.mcp.json` 的 `windows-ui` endpoint:`http://10.1.1.101:8765/mcp`;连接成功。
|
||||
- Windows `WxAgent.Tray` 在 Session 1 运行;微信版本由 status 报告为 `4.1.13.65`。
|
||||
- `GET /api/v1/status` 返回 200:service、WeChat、session、window 和 active account binding 均可用,未使用 Session 0。
|
||||
- UI target `11460:81724928` 与账号 Rogee 绑定,`isBound=true`。
|
||||
- 本轮在单 Client 的 `service.json` 显式启用 `enableValidationOperations=true`;`local-admin` 凭据获得 `write` 权限。代码默认仍为关闭,普通部署不自动放行写操作。
|
||||
- capability 当前启用 `session-open`、`sessions-scroll`、`send-text`、`broadcast-text`;两种写能力仅因本机明确授权的 `enableValidationOperations=true` 而开放,普通部署仍保持只读。`group-at-all` 保持 disabled,因为当前 Client 没有注册该独立操作。群消息和受控群发均使用已确认的会话 automation ID。
|
||||
|
||||
## 只读结果
|
||||
|
||||
- UIA 真实会话列表导出 16 个条目,包含“文件传输助手”“Hao 豪”“吉祥三宝”等。
|
||||
- 当前会话读取成功为“文件传输助手”。消息 API 返回 200,4 条真实消息,类型为 Text/Merge/File/Text;使用 `includeContent=false` 验证脱敏读取。
|
||||
- 联系人读取分别命中“文件传输助手”“Hao 豪”“吉祥三宝”;群查询命中“消息测试专用群组”(群 ID 不写入本记录)。
|
||||
- 名称不唯一/不存在时 API 返回 409 NotFound,`retry=false`;没有自动猜测、跨目标改投或自动重试。
|
||||
|
||||
## 写操作结果
|
||||
|
||||
### 文件传输助手
|
||||
|
||||
- 首次通过服务操作提交的错误 target 格式(联系人 ID `filehelper`)得到 `Unconfirmed / ExecutionFailed`,`hasSideEffects=true`;未重放、未自动重试。
|
||||
- 随后使用 Windows MCP 在真实微信输入框发送唯一测试标记 `[WxAgent验证] UI 单 Client 发送验证 20260919-0239`;UIA 消息列表出现该消息,写后确认通过。
|
||||
|
||||
### 吉祥三宝群
|
||||
|
||||
- 使用可见会话 automation ID `session_item_吉祥三宝` 提交 confirmed `send-text`,操作 ID:`c3be6c1e07c14c2f9fdc685357c44993`。
|
||||
- 结果:`state=Succeeded`、`stage=complete`、`hasSideEffects=true`。
|
||||
- 相同 idempotency key 重放返回同一 operation ID,没有创建重复操作;验证了幂等防重。
|
||||
- 微信真实会话列表的“吉祥三宝”预览出现唯一测试标记 `[WxAgent验证] 单 Client 群消息验证 吉祥三宝 20260919-0258`,作为写后 UI 确认。
|
||||
- `confirmed=false` 的负向请求返回 HTTP 409 `ConfirmationRequired`、`retry=false`,没有执行。
|
||||
|
||||
### 多目标文本群发(broadcast-text)
|
||||
|
||||
- 原始 HTTP 响应、请求 fixture、能力/授权状态、幂等重放、确认门禁、只读 sessions/messages 响应和 MCP UIA 摘录已保存于 [`evidence/WebUI-MCP-single-client-2026-09-19/`](evidence/WebUI-MCP-single-client-2026-09-19/),其中 `README.md`、`manifest.json` 和 `sha256sums.txt` 说明采集边界、脱敏规则和校验方式。
|
||||
- 使用真实 Client API 提交 `kind=broadcast-text`,请求携带两个当前可见且唯一的会话 automation ID:`session_item_文件传输助手`、`session_item_吉祥三宝`,并设置 `confirmed=true`、`stopOnError=true`。服务在执行前冻结该名单,不使用联系人 ID 猜测或跨目标改投。
|
||||
- 首次诊断操作 `7b3a9ff33550489eb7fe50478bbd1155` 曾在文件传输助手成功后因吉祥三宝 `ControlNotFound` 按 `stopOnError` 停止;该记录保留为失败停止和幂等证据,未自动重试。
|
||||
- 修正 UI 会话切换为可见中心点击,并将等待聊天页从 2.5 秒扩大到 10 秒后,重新部署最新 self-contained Tray。最终验收操作 ID:`463b37b2e0944525af4ded822dddaf8c`;Correlation ID:`33d6e14673834c64b5d470a07687c760`;`state=Succeeded`、`stage=complete`、`errorCode=null`。
|
||||
- 最终操作冻结同一两个白名单目标:文件传输助手 `Succeeded`、吉祥三宝 `Succeeded`;`stopped=false`、`stopReason=null`。这证明了真实多对象名单冻结、串行逐项发送、两个目标写后确认和成功群发。
|
||||
- 相同 `idempotencyKey=wxagent-single-20260919-broadcast-final-001` 重放返回同一个操作 ID和相同 `Succeeded` 结果(重放请求 Correlation ID:`858fd45fc16e4b1383909d4d406ae8bc`),没有创建第二次群发。服务 capability 实际返回 `send-text`/`broadcast-text` 为 `implemented=true`、`validated=true`、`enabled=true`、`requiresConfirmation=true`,微信版本 `4.1.13.65`;`group-at-all` 仍为 disabled。
|
||||
- 群发结束后分别用只读 `GET /api/v1/messages` 查询两个 automation ID 对应会话,均返回 4 条可见消息,并在不记录完整正文的前提下确认本次唯一标记存在(`markerConfirmed=true`)。
|
||||
|
||||
### 未通过/保持禁用
|
||||
|
||||
- `消息测试专用群组` 当前搜索结果存在多个同名项,使用联系人群 ID 的操作返回 `Unconfirmed / NotFound`;未猜测目标、未重试,仍保持该路径禁用。
|
||||
- `group-at-all` 独立能力没有注册操作,仍禁用;本轮不宣称已验证 @所有人。
|
||||
- 添加好友未验证,入口继续禁用。
|
||||
- 多 Client、跨 Client 故障恢复和远程控制面任务流不属于本轮证据。
|
||||
|
||||
## 自动检查
|
||||
|
||||
- `cd control-plane && go test ./...`:通过。
|
||||
- `cd control-plane/web && npm run build`:通过。
|
||||
- `dotnet test tests/node-agent/WxAgent.Core.Tests -c Release --no-restore`:168 tests 通过。
|
||||
- `dotnet test tests/node-agent/WxAgent.Service.Tests -c Release --no-restore`:22 tests 通过,包含 `broadcast-text` 冻结、顺序、停止和幂等覆盖。
|
||||
- `dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore`:0 warnings、0 errors。
|
||||
- `git diff --check`、Go `gofmt`:通过。
|
||||
|
||||
## 风险与后续
|
||||
|
||||
- 版本证据不一致已在最终部署中修正:`GET /api/v1/capabilities` 与 status 均报告微信 `4.1.13.65`,send/broadcast 均为 `validated=true`。
|
||||
- 首次诊断中的错误 target 保留为失败停止证据;没有重试该不确定操作,修复后使用新的显式幂等键完成最终成功验收。
|
||||
- 早期诊断曾出现 60 秒 sessions 队列超时;修复后连续 3 次 sessions 刷新均 HTTP 200、约 2.1–2.3 秒,且两个目标的只读 marker 回读均成功,不再作为本轮验收阻塞项。
|
||||
- 当前验证模式只应留在这台明确授权的单 Client;未授权部署必须保持默认只读。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 单 Client 真实验证原始证据
|
||||
|
||||
本目录保存 2026-09-19 在已登录、未锁定的 Windows Session 1 上采集的原始 HTTP API 响应、请求 fixture、MCP UIA 证据摘录和校验清单。它补充 `../WebUI-MCP-single-client-2026-09-19.md`,不是用文档自述替代接口响应。
|
||||
|
||||
## 采集边界
|
||||
|
||||
- Windows UI MCP:`.mcp.json` 中的 `windows-ui`,`http://10.1.1.101:8765/mcp`。
|
||||
- UI MCP 用于确认真实微信窗口、`MainView`、`session_list`、两个白名单 automation ID、`chat_message_list` 和验收标记;对应的可审计摘录见 `mcp-ui-evidence.json`。
|
||||
- Agent HTTP API 在 Windows 测试服务的本地转发端口访问:`http://127.0.0.1:15088`,请求 Host 为 `localhost:5088`。这不是把 `/api/v1/*` 当作 MCP endpoint,而是记录 `.mcp.json` 连接的真实 Client 所运行服务的认证 API 响应。
|
||||
- 每个 `*.http.txt` 是原始响应头,每个 `*.response.json` 是原始响应体;文件中没有 Bearer token。测试 token 只从本地/远端验证配置读取,未复制到仓库。
|
||||
- 消息只读证据使用 `includeContent=false`,保留条数、fingerprint 和 summary 中的唯一测试标记,不保存完整聊天正文。
|
||||
|
||||
## 证据对应关系
|
||||
|
||||
| 文件 | 证明内容 |
|
||||
| --- | --- |
|
||||
| `status.*` | 服务在线、微信可用、Session 未锁定、账号绑定、微信版本 `4.1.13.65`、默认只读 |
|
||||
| `capabilities.*` | `send-text`/`broadcast-text` 为 implemented、validated、enabled;群发要求确认;`group-at-all` disabled |
|
||||
| `sessions-1..3.*` | 连续 3 次只读会话枚举均 HTTP 200,均含 `session_item_文件传输助手` 和 `session_item_吉祥三宝` |
|
||||
| `broadcast.request.json` | 最终真实群发请求:两个 automation ID、显式确认、幂等键、`stopOnError=true` |
|
||||
| `broadcast-submit.*` | 原始 POST 受理响应:HTTP 200、operation `463b37b2e0944525af4ded822dddaf8c`、`Queued`、冻结目标详情 |
|
||||
| `broadcast-get.*` | 原始最终操作响应:operation `463b37b2e0944525af4ded822dddaf8c`、`Succeeded/complete`、两个逐项 `Succeeded` |
|
||||
| `broadcast-replay.*` | 相同幂等请求的原始重放响应,返回相同 operation ID 和结果 |
|
||||
| `confirmation-gate.*` | `confirmed=false` 的真实 `broadcast-text` 请求返回 HTTP 409 `ConfirmationRequired`,`retry=false` |
|
||||
| `send-confirmation-gate.*` | `confirmed=false` 的真实 `send-text` 请求返回 HTTP 409 `ConfirmationRequired`,`retry=false` |
|
||||
| `messages-filehelper.*`, `messages-jixiang.*` | 两个目标的原始只读消息响应;summary 含本次唯一标记、content 为 null |
|
||||
| `readback-summary.json` | 从上述 raw 只读响应计算的脱敏回读结论 |
|
||||
| `mcp-ui-evidence.json` | UI MCP 实时窗口/UIA 树中真实微信、白名单会话、消息列表和标记的证据摘录 |
|
||||
|
||||
## 关键断言
|
||||
|
||||
- 最终 operation:`463b37b2e0944525af4ded822dddaf8c`,Correlation ID:`33d6e14673834c64b5d470a07687c760`。
|
||||
- 最终详情的冻结顺序为文件传输助手、吉祥三宝;两项均 `Succeeded`,`stopped=false`,无错误码。
|
||||
- 相同幂等键 `wxagent-single-20260919-broadcast-final-001` 的重放仍返回该 operation ID,不创建第二次操作。
|
||||
- 失败停止流程的早期原始记录仍在主验收文档中,避免只保留成功路径;最终验收使用了新的显式幂等键。
|
||||
- `group-at-all`、添加好友、多 Client/跨 Client 恢复不属于本轮证据。
|
||||
|
||||
## 复核方式
|
||||
|
||||
在已授权的验证机上设置临时 shell 变量 `TOKEN` 后,可按 `*.http.txt` 中的 Host 和路径重放 GET;不得把 token 写入文件或日志。POST 请求只能使用目录中的 confirmation-gate fixture(应返回 409)或最终 broadcast fixture(相同幂等键应返回相同操作),不得绕过确认门禁或使用真实非白名单对象。
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:22:44 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 35ec468485244ba98ba9751327d6b5f7
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Succeeded","stage":"complete","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"items\":[{\"targetId\":\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"state\":\"Succeeded\",\"errorCode\":null},{\"targetId\":\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\",\"state\":\"Succeeded\",\"errorCode\":null}],\"stopped\":false,\"stopReason\":null}"}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:22:44 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 94d46404d54a43babfe5bb855f767518
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Succeeded","stage":"complete","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"items\":[{\"targetId\":\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"state\":\"Succeeded\",\"errorCode\":null},{\"targetId\":\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\",\"state\":\"Succeeded\",\"errorCode\":null}],\"stopped\":false,\"stopReason\":null}"}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:09:45 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 57fc0ed311354f1586bcfd308dd54c7e
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"id":"463b37b2e0944525af4ded822dddaf8c","principalId":"local-admin","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","capability":"broadcast-text","state":"Queued","stage":"queued","correlationId":"33d6e14673834c64b5d470a07687c760","createdAt":"2026-09-19T04:09:45.5025818+00:00","expiresAt":"2026-09-19T04:14:45.5025818+00:00","errorCode":null,"hasSideEffects":true,"details":"{\"targetIds\":[\"session_item_\\u6587\\u4EF6\\u4F20\\u8F93\\u52A9\\u624B\",\"session_item_\\u5409\\u7965\\u4E09\\u5B9D\"],\"stopOnError\":true}"}
|
||||
@@ -0,0 +1 @@
|
||||
{"kind":"broadcast-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targets":["session_item_文件传输助手","session_item_吉祥三宝"],"text":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","idempotencyKey":"wxagent-single-20260919-broadcast-final-001","confirmed":true,"stopOnError":true}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:22:44 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 65c675b23d324a6ea793c356da41fb51
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
File diff suppressed because one or more lines are too long
+10
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 409 Conflict
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:15:34 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 3183a2753a044909b14f73336dcbe170
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"kind":"broadcast-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targets":["session_item_文件传输助手","session_item_吉祥三宝"],"text":"[WxAgent验证] confirmation gate must not send 20260919-0414","idempotencyKey":"wxagent-single-20260919-confirmation-gate-001","confirmed":false,"stopOnError":true}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"correlationId":"3183a2753a044909b14f73336dcbe170","error":{"code":"ConfirmationRequired","message":"Explicit send confirmation is required.","stage":"request","retry":false}}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"collectedAtUtc": "2026-09-19T04:15:34Z",
|
||||
"purpose": "Auditable raw evidence for the final single-Client Windows WeChat validation.",
|
||||
"mcp": {
|
||||
"config": ".mcp.json",
|
||||
"server": "windows-ui",
|
||||
"endpoint": "http://10.1.1.101:8765/mcp",
|
||||
"uiEvidence": "mcp-ui-evidence.json"
|
||||
},
|
||||
"serviceApi": {
|
||||
"baseUrl": "http://127.0.0.1:15088",
|
||||
"hostHeader": "localhost:5088",
|
||||
"authentication": "Bearer token used only by the local validation shell; no token is stored in this directory."
|
||||
},
|
||||
"boundAccountId": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277",
|
||||
"wechatVersion": "4.1.13.65",
|
||||
"finalOperation": {
|
||||
"id": "463b37b2e0944525af4ded822dddaf8c",
|
||||
"submitCorrelationId": "57fc0ed311354f1586bcfd308dd54c7e",
|
||||
"correlationId": "33d6e14673834c64b5d470a07687c760",
|
||||
"submitResponse": "broadcast-submit.response.json",
|
||||
"capability": "broadcast-text",
|
||||
"targets": ["session_item_文件传输助手", "session_item_吉祥三宝"],
|
||||
"state": "Succeeded",
|
||||
"stage": "complete",
|
||||
"itemStates": ["Succeeded", "Succeeded"],
|
||||
"stopped": false,
|
||||
"replayResponse": "broadcast-replay.response.json"
|
||||
},
|
||||
"confirmationGates": {
|
||||
"broadcastText": {
|
||||
"request": "confirmation-gate.request.json",
|
||||
"httpStatus": 409,
|
||||
"errorCode": "ConfirmationRequired",
|
||||
"retry": false,
|
||||
"correlationId": "3183a2753a044909b14f73336dcbe170"
|
||||
},
|
||||
"sendText": {
|
||||
"request": "send-confirmation-gate.request.json",
|
||||
"httpStatus": 409,
|
||||
"errorCode": "ConfirmationRequired",
|
||||
"retry": false,
|
||||
"correlationId": "78820fff9db246feb032a873018d43bb"
|
||||
}
|
||||
},
|
||||
"readOnlyEvidence": {
|
||||
"status": "status.response.json",
|
||||
"capabilities": "capabilities.response.json",
|
||||
"sessions": ["sessions-1.response.json", "sessions-2.response.json", "sessions-3.response.json"],
|
||||
"messages": ["messages-filehelper.response.json", "messages-jixiang.response.json"]
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "SHA-256",
|
||||
"fileList": "sha256sums.txt",
|
||||
"scope": "All evidence files except manifest.json and sha256sums.txt; README and derived summaries are included."
|
||||
},
|
||||
"redactions": [
|
||||
"Authorization headers/tokens are not stored.",
|
||||
"Message API evidence uses includeContent=false; full chat bodies are not stored.",
|
||||
"The bound account ID and test marker are retained because they are required to correlate the operation and read-back evidence."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": {
|
||||
"mcpConfig": ".mcp.json",
|
||||
"server": "windows-ui",
|
||||
"endpoint": "http://10.1.1.101:8765/mcp",
|
||||
"listTool": "windows-ui_list_windows",
|
||||
"inspectTool": "windows-ui_inspect_window",
|
||||
"inspectArgs": {"title_re": "^微信$", "found_index": 0},
|
||||
"note": "This is a redacted, auditable excerpt of the live MCP UIA response; rectangles and automation IDs are copied from the tool result. Message bodies are not stored here."
|
||||
},
|
||||
"visibleTopLevelWindows": ["任务栏", "微信", "操作提示", "设置标题", "JP-Word->033.jpwabc ==> C:\\temp\\jpw7-batch\\, (cpu=0.0)", "微信", "新标签页 - 夸克", "10.1.1.104 - Google Chrome", "QQ", "dummyLayeredWnd", "dummyLayeredWnd", "Program Manager"],
|
||||
"requiredNodes": [
|
||||
{"name": "微信", "type": "Window", "automationId": "", "rectangle": "(L371, T56, R1204, B729)"},
|
||||
{"name": "", "type": "Group", "automationId": "MainView", "rectangle": "(L371, T57, R1204, B729)"},
|
||||
{"name": "会话", "type": "List", "automationId": "session_list", "rectangle": "(L431, T137, R671, B677)"},
|
||||
{"name": "文件传输助手\\n已置顶\\nNative Writer\\n12:17\\n", "type": "ListItem", "automationId": "session_item_文件传输助手", "rectangle": "(L431, T137, R671, B202)"},
|
||||
{"name": "吉祥三宝\\n已置顶\\n[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412\\n12:09\\n", "type": "ListItem", "automationId": "session_item_吉祥三宝", "rectangle": "(L431, T202, R671, B267)"},
|
||||
{"name": "", "type": "Group", "automationId": "chat_message_page", "rectangle": "(L671, T89, R1200, B725)"},
|
||||
{"name": "文件传输助手", "type": "Text", "automationId": "content_view.top_content_view.title_h_view.left_v_view.left_content_v_view.left_ui_.big_title_line_h_view.current_chat_name_label", "rectangle": "(L687, T102, R771, B122)"},
|
||||
{"name": "消息", "type": "List", "automationId": "chat_message_list", "rectangle": "(L671, T137, R1200, B585)"},
|
||||
{"name": "[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412", "type": "ListItem", "automationId": "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view", "rectangle": "(L671, T413, R1200, B488)"},
|
||||
{"name": "文件传输助手", "type": "Edit", "automationId": "chat_input_field", "rectangle": "(L691, T594, R1184, B673)"},
|
||||
{"name": "", "type": "ToolBar", "automationId": "tool_bar_accessible", "rectangle": "(L679, T673, R1192, B717)"}
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:16:44 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 56cc6ebfac8f4c59b26ca9e832149e8f
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"items":[{"fingerprint":"6b2c72487f9a1e179d0b4f2bb8d50038a683b3a5c9d7404aae9cbe1ef2356923","type":"Text","sender":null,"summary":"wxagent-smoke-20260918161447-254c4428e37c46c996c0fa4af0841a55","content":null},{"fingerprint":"8a50e9216c22a893332c8458979bdb9d4a45aa23a8dea1d36199fcf60c2c4aee","type":"Text","sender":null,"summary":"ome[WxAgent验证] UI 单 Client 发送验证 20260919-0239","content":null},{"fingerprint":"0c62388df1125439daf09f1911bb7d3aa27b1b1fb0ecba4d6f49fda74de37567","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播 20260919-0328","content":null},{"fingerprint":"837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","content":null}],"limit":50,"offset":0,"hasMore":false,"nextOffset":null}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:16:50 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: ec106963f1be4f45b3e5d9b950e8997d
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"items":[{"fingerprint":"6b2c72487f9a1e179d0b4f2bb8d50038a683b3a5c9d7404aae9cbe1ef2356923","type":"Text","sender":null,"summary":"wxagent-smoke-20260918161447-254c4428e37c46c996c0fa4af0841a55","content":null},{"fingerprint":"8a50e9216c22a893332c8458979bdb9d4a45aa23a8dea1d36199fcf60c2c4aee","type":"Text","sender":null,"summary":"ome[WxAgent验证] UI 单 Client 发送验证 20260919-0239","content":null},{"fingerprint":"0c62388df1125439daf09f1911bb7d3aa27b1b1fb0ecba4d6f49fda74de37567","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播 20260919-0328","content":null},{"fingerprint":"837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86","type":"Text","sender":null,"summary":"[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412","content":null}],"limit":50,"offset":0,"hasMore":false,"nextOffset":null}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"sourceFiles": [
|
||||
"messages-filehelper.response.json",
|
||||
"messages-jixiang.response.json"
|
||||
],
|
||||
"query": "GET /api/v1/messages?accountId=<bound-account>&session=<session-name>&limit=50&includeContent=false",
|
||||
"contentWasStored": false,
|
||||
"marker": "[WxAgent验证] 单 Client 多对象广播修复验收 20260919-0412",
|
||||
"filehelper": {
|
||||
"itemCount": 4,
|
||||
"markerFoundInSummary": true,
|
||||
"allContentFieldsNull": true,
|
||||
"lastMessageFingerprint": "837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86"
|
||||
},
|
||||
"jixiang": {
|
||||
"itemCount": 4,
|
||||
"markerFoundInSummary": true,
|
||||
"allContentFieldsNull": true,
|
||||
"lastMessageFingerprint": "837b6ef9c70f1ddfa462cc9d973b312e71935539a8e493c519b9099de0fc6c86"
|
||||
},
|
||||
"interpretation": "The raw read-only responses contain the unique test marker in summary and omit full message bodies; the matching final fingerprint is an independent post-send read-back signal for both target sessions."
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 409 Conflict
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:25:03 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 78820fff9db246feb032a873018d43bb
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"kind":"send-text","accountId":"a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277","targetId":"session_item_吉祥三宝","text":"[WxAgent验证] 不应发送","idempotencyKey":"wxagent-single-20260919-confirmation-negative","confirmed":false}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"correlationId":"78820fff9db246feb032a873018d43bb","error":{"code":"ConfirmationRequired","message":"Explicit send confirmation is required.","stage":"request","retry":false}}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:16:35 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: fd1f07e563774a238c1da3d85ccdf470
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
@@ -0,0 +1 @@
|
||||
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:16:38 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: a5655c213ccd4d82a4b761200618269b
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
@@ -0,0 +1 @@
|
||||
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:16:40 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: 7c10f8eacba04e4ca13cbc896379e177
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
@@ -0,0 +1 @@
|
||||
{"items":[{"name":"吉祥三宝","automationId":"session_item_吉祥三宝","isCurrent":false},{"name":"文件传输助手","automationId":"session_item_文件传输助手","isCurrent":true},{"name":"运营3部日报","automationId":"session_item_运营3部日报","isCurrent":false},{"name":"李晓敏","automationId":"session_item_李晓敏","isCurrent":false},{"name":"杨礼溪","automationId":"session_item_杨礼溪","isCurrent":false},{"name":"\uD83C\uDF3B2026级初一5班家长群","automationId":"session_item_\uD83C\uDF3B2026级初一5班家长群","isCurrent":false},{"name":"外呼机器人研发对接","automationId":"session_item_外呼机器人研发对接","isCurrent":false},{"name":"商务通系统对接问题反馈","automationId":"session_item_商务通系统对接问题反馈","isCurrent":false},{"name":"潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","automationId":"session_item_潘卫东-潘总、刘猛、邵存阳-邵总、根成、高总公司-潘卫东、min","isCurrent":false}],"limit":20,"offset":0,"hasMore":false,"nextOffset":null}
|
||||
@@ -0,0 +1,30 @@
|
||||
3ad3c85c315c6f6d831b3d84f894a3cd34ba237b8ba0d8401d814f8beb6be75a broadcast-get.http.txt
|
||||
4e01ccec54fa1fd023d150d84ee45c698aa5a08cce4b20048ee6a3d84d41336d broadcast-get.response.json
|
||||
0de4d771c51fb7f812c3521771cf8126dd015a90f2cfe7efa07b64431993b72b broadcast-replay.http.txt
|
||||
4e01ccec54fa1fd023d150d84ee45c698aa5a08cce4b20048ee6a3d84d41336d broadcast-replay.response.json
|
||||
390c3db2dbbbd40886125c41a308c0fd09de34a893abd1594f62e031fb85d834 broadcast.request.json
|
||||
edaf2a0fd912158a64600afea898ef7e92e1f952b99e8f50dcdf23aff5e3fe73 broadcast-submit.http.txt
|
||||
8889cbed1f14be4f767cdc3a0ee372f75ba2982e6dba4f53504a98907d9406b2 broadcast-submit.response.json
|
||||
f0c05ef72bab694caf2d890bfc0fb6fd69fd01717c4b24365d6bb34dc50ef571 capabilities.http.txt
|
||||
ba09345de35f48ed812cbe54e6298c68f127c0b9cea6ee426e21c3669b16bb04 capabilities.response.json
|
||||
af5e1cd3d73f4f332a8a32bda24bbc814ab0d90e85ecf6eed6b0b4585be269e9 confirmation-gate.http.txt
|
||||
4dcde0d485bb221a4846cc53e225f05bb944433c939f5b7595c3a1ff700b9f82 confirmation-gate.request.json
|
||||
fc0cec0c57504482651dc664903eef4d493a39c12357504282bfa58266388455 confirmation-gate.response.json
|
||||
529430b446c9b8b1d05c7fcb51514e8ec1b71627af5ffe1c30d29791c83fa008 mcp-ui-evidence.json
|
||||
64bc614875c15a7ab488fa9740bab0f606c9234596a5e903a88d2e68e22ca8b4 messages-filehelper.http.txt
|
||||
b825e65ba2f3f5d27817ef64273785cb9be09cf9df8dfde6afe01794659f7994 messages-filehelper.response.json
|
||||
63fceae775c345adc899b29b0a39d72906a662ba58a20eccea34cd0d6116f842 messages-jixiang.http.txt
|
||||
b825e65ba2f3f5d27817ef64273785cb9be09cf9df8dfde6afe01794659f7994 messages-jixiang.response.json
|
||||
54c40a6e30b2dbaa50f8027727b7aeb22cedc4998177c3fe350a44f17e155816 readback-summary.json
|
||||
623571c8a2bd35f089906e8587c01d5db157e2af5d8b908bcf26d39e0c9b7380 README.md
|
||||
344b3852dec53fc2b927669b5643863c7a4c184f2ac41d99374e6f16a83ff53e send-confirmation-gate.http.txt
|
||||
86ed9bc17d74b0ccf463b6519e25df576520b263b4d16eda79864b7500681cc3 send-confirmation-gate.request.json
|
||||
e2e07ecbbdd276f71e78dfe9160abb04d2bef923b73ae8b0b21eed759806461f send-confirmation-gate.response.json
|
||||
dc0821a7b6f37f314e7fcd2aa8d68e96c27805cf0fea8e0cb03517f36ecea247 sessions-1.http.txt
|
||||
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-1.response.json
|
||||
843811528eb530326e7d49b4be3fffbb11ad89cd2882232221ff6dca61dcbb4e sessions-2.http.txt
|
||||
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-2.response.json
|
||||
b0499282b98c3bf946c53698a8dcf8de830396a8e9d42b38451168d8c3dcf554 sessions-3.http.txt
|
||||
976c98db5de557fffd1a7d8ba6c937925d791da4df6b5f6838983c09b4253f9b sessions-3.response.json
|
||||
fc2fa5ecff7f2c9ad9c71842b725d78d4f999c283e11832989f10bc7fbc910a0 status.http.txt
|
||||
0280b1df77f973805bb487506002eac4e3ec2d67c8d68d58c1603f9b93e33eca status.response.json
|
||||
@@ -0,0 +1,10 @@
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Date: Sat, 19 Sep 2026 04:22:44 GMT
|
||||
Server: Kestrel
|
||||
Cache-Control: no-store
|
||||
Transfer-Encoding: chunked
|
||||
X-Correlation-Id: acfbaa6b604646268b3892d050e8a4aa
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
|
||||
@@ -0,0 +1 @@
|
||||
{"serviceOnline":true,"wechatAvailable":true,"sessionAvailable":true,"sessionLocked":false,"windowFound":true,"errors":[],"wechatVersions":["4.1.13.65"],"activeAccountBound":true,"defaultReadOnly":true}
|
||||
@@ -33,6 +33,7 @@ public enum RemoteNodeStatus
|
||||
public enum RemoteTaskStatus
|
||||
{
|
||||
Pending,
|
||||
WaitingForClient,
|
||||
Accepted,
|
||||
Running,
|
||||
Succeeded,
|
||||
@@ -246,7 +247,8 @@ public sealed record RemoteNodeRegistration(
|
||||
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
|
||||
[property: JsonPropertyName("capabilities")] IReadOnlyList<string> Capabilities,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts);
|
||||
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteAccountSummary(
|
||||
[property: JsonPropertyName("account_id")] string AccountId,
|
||||
@@ -259,13 +261,15 @@ public sealed record RemoteNodeRegistrationResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("authenticated")] bool Authenticated,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteHeartbeatResponse(
|
||||
[property: JsonPropertyName("node_id")] string NodeId,
|
||||
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
|
||||
[property: JsonPropertyName("last_heartbeat_at")] DateTimeOffset LastHeartbeatAt,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId);
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteTaskBatch(
|
||||
[property: JsonPropertyName("tasks")] IReadOnlyList<RemoteTaskEnvelope> Tasks);
|
||||
@@ -282,7 +286,8 @@ public sealed record RemoteHeartbeat(
|
||||
[property: JsonPropertyName("queue_length")] int QueueLength,
|
||||
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
|
||||
[property: JsonPropertyName("correlation_id")] string CorrelationId,
|
||||
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null);
|
||||
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null,
|
||||
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
|
||||
|
||||
public sealed record RemoteTaskEnvelope(
|
||||
[property: JsonPropertyName("task_id")] string TaskId,
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
private readonly Uri? _baseAddress;
|
||||
private readonly X509Certificate2? _clientCertificate;
|
||||
private readonly X509Certificate2? _serverCaCertificate;
|
||||
private string? _connectionId;
|
||||
private bool _authenticated;
|
||||
|
||||
public RemoteControlClient(RemoteAgentOptions options, HttpClient? httpClient = null)
|
||||
@@ -57,7 +58,9 @@ public sealed class RemoteControlClient : IDisposable
|
||||
AuthState = RemoteAuthState.Authenticating;
|
||||
try
|
||||
{
|
||||
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration, false, cancellationToken);
|
||||
var connectionId = registration.ConnectionId ?? NewConnectionId();
|
||||
var response = await SendAsync<RemoteNodeRegistrationResponse>(HttpMethod.Post, "/v1/nodes/register", registration with { ConnectionId = connectionId }, false, cancellationToken);
|
||||
_connectionId = response.ConnectionId ?? connectionId;
|
||||
_authenticated = response.Authenticated;
|
||||
AuthState = response.Authenticated ? RemoteAuthState.Authenticated : RemoteAuthState.AuthenticationFailed;
|
||||
LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
@@ -75,7 +78,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
|
||||
public Task<RemoteHeartbeatResponse> HeartbeatAsync(RemoteHeartbeat heartbeat, CancellationToken cancellationToken = default) =>
|
||||
SendAuthenticatedAsync<RemoteHeartbeatResponse>(HttpMethod.Post,
|
||||
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat, cancellationToken);
|
||||
$"/v1/nodes/{Escape(heartbeat.NodeId)}/heartbeat", heartbeat with { ConnectionId = heartbeat.ConnectionId ?? _connectionId }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<RemoteTaskEnvelope>> PollTasksAsync(
|
||||
string accountId,
|
||||
@@ -205,7 +208,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = TryDeserializeError(responseBytes, correlationId);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || error.Code == "StaleConnection")
|
||||
{
|
||||
_authenticated = false;
|
||||
AuthState = RemoteAuthState.AuthenticationFailed;
|
||||
@@ -292,6 +295,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value);
|
||||
private static string NewConnectionId() => Guid.NewGuid().ToString("N");
|
||||
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -31,7 +31,7 @@ try
|
||||
var serviceOptions = JsonSerializer.Deserialize<ServiceOptions>(
|
||||
await File.ReadAllTextAsync(configPath, shutdown.Token), ServiceHost.ConfigurationJson)
|
||||
?? throw new ArgumentException("A service configuration is required.");
|
||||
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions)),
|
||||
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions), serviceOptions),
|
||||
logPath: Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"));
|
||||
await app.StartAsync(shutdown.Token);
|
||||
try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); }
|
||||
|
||||
@@ -4,33 +4,38 @@ using WxAgent.Windows;
|
||||
|
||||
namespace WxAgent.Host;
|
||||
|
||||
public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
|
||||
public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOptions options) : IAgentBackend, IAgentEventSource
|
||||
{
|
||||
private const bool ListenerEventsEnabled = true;
|
||||
private readonly bool validationOperationsEnabled = options.EnableValidationOperations;
|
||||
private readonly SemaphoreSlim bindingGate = new(1, 1);
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities { get; } =
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("agent-status", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("agent-diagnose", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("accounts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("account-binding", true, false, true, true, true, "manage", false, 30, "Auto-binds a unique database/UI identity match; manual binding remains available as fallback.", ["docs/WebUI-MCP-开发计划.md"]),
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
||||
new("session-open", true, false, false, true, true, "manage", false, 30, "Requires manage permission and navigation acceptance.", ["Navigation acceptance pending."], "4.1.13.63"),
|
||||
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"),
|
||||
new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"),
|
||||
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("sessions-search", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("session-current", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("sessions-scroll", true, false, validationOperationsEnabled, true, false, "manage", false, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
|
||||
new("session-open", true, false, validationOperationsEnabled, true, true, "manage", false, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["Controlled validation mode."], "4.1.13.65"),
|
||||
new("messages-read", true, true, true, true, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("contacts-list", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
|
||||
new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.65"),
|
||||
new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("listener-events", true, ListenerEventsEnabled, ListenerEventsEnabled, true, false, "read", false, 30,
|
||||
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"),
|
||||
new("send-text", true, false, false, true, true, "write", false, 30,
|
||||
"Active UI/database account binding has not been verified for this service session.", []),
|
||||
ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("send-text", true, true, validationOperationsEnabled, true, true, "write", true, 30,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("broadcast-text", true, true, validationOperationsEnabled, true, true, "write", true, 300,
|
||||
validationOperationsEnabled ? null : "Validation operations are disabled for this service session.", ["docs/validation/WebUI-MCP-single-client-2026-09-19.md"], "4.1.13.65"),
|
||||
new("group-at-all", true, false, false, true, true, "write", true, 30,
|
||||
"Service account binding and group-owner validation required.", []),
|
||||
"No group-at-all operation is registered; validate group targets through confirmed send-text tasks.", []),
|
||||
new("next-unread", false, false, false, true, false, "read", false, 30,
|
||||
"Deferred by docs/PENDING.md.", [])
|
||||
];
|
||||
|
||||
@@ -133,19 +133,134 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
|
||||
public Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) =>
|
||||
MessagesAsync(null, session, limit, offset, includeContent, ct);
|
||||
|
||||
public OperationRecord SubmitOperation(OperationSubmitRequest request, CancellationToken ct)
|
||||
public async Task<OperationRecord> SubmitOperationAsync(OperationSubmitRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!string.Equals(request.Kind, "send-text", StringComparison.Ordinal))
|
||||
throw new ServiceException("UnsupportedOperation", 400, "Only send-text is available in this phase.");
|
||||
var kind = RequireText(request.Kind, "kind", 80);
|
||||
var accountId = RequireText(request.AccountId, "accountId", 200);
|
||||
var targetId = RequireText(request.TargetId, "targetId", 512);
|
||||
var text = PrepareText(request.Text);
|
||||
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
|
||||
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
|
||||
var capability = RequireCapability("send-text");
|
||||
var canonical = System.Text.Json.JsonSerializer.Serialize(new { request.Kind, accountId, targetId, text });
|
||||
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
|
||||
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
|
||||
|
||||
if (string.Equals(kind, "send-text", StringComparison.Ordinal))
|
||||
{
|
||||
var targetId = RequireText(request.TargetId, "targetId", 512);
|
||||
var capability = RequireCapability("send-text");
|
||||
var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text });
|
||||
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
|
||||
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
|
||||
}
|
||||
|
||||
if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal))
|
||||
throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase.");
|
||||
if (!string.IsNullOrWhiteSpace(request.TargetId))
|
||||
throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId.");
|
||||
|
||||
var capabilityForBroadcast = RequireCapability("broadcast-text");
|
||||
var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false);
|
||||
var initialDetails = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
targetIds = targets,
|
||||
stopOnError = request.StopOnError
|
||||
}, ServiceHost.Json);
|
||||
var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
kind,
|
||||
accountId,
|
||||
targetIds = targets,
|
||||
text,
|
||||
stopOnError = request.StopOnError
|
||||
});
|
||||
return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast,
|
||||
initialDetails,
|
||||
cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList<string>? requested, CancellationToken ct)
|
||||
{
|
||||
if (requested is null || requested.Count is < 1 or > 20)
|
||||
throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets.");
|
||||
|
||||
var targets = requested
|
||||
.Select(target => RequireText(target, "target", 512))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target.");
|
||||
|
||||
// Freeze the current UI target list before enqueueing. The account binding and identity are
|
||||
// still revalidated by every side-effecting send, so this preflight cannot bypass account safety.
|
||||
var visible = await backend.SessionsAsync(ct).ConfigureAwait(false);
|
||||
var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal);
|
||||
var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session.");
|
||||
return targets;
|
||||
}
|
||||
|
||||
private async Task<OperationExecutionResult> ExecuteBroadcastAsync(string accountId, IReadOnlyList<string> targets,
|
||||
string text, bool stopOnError, CancellationToken ct)
|
||||
{
|
||||
var items = new List<BroadcastItemResult>(targets.Count);
|
||||
var stopped = false;
|
||||
string? stopReason = null;
|
||||
string? errorCode = null;
|
||||
var unconfirmed = false;
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false);
|
||||
items.Add(new BroadcastItemResult(target, "Succeeded", null));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled"));
|
||||
stopped = true;
|
||||
stopReason = "Cancelled";
|
||||
errorCode = "Cancelled";
|
||||
unconfirmed = true;
|
||||
break;
|
||||
}
|
||||
catch (ServiceException exception)
|
||||
{
|
||||
var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed";
|
||||
items.Add(new BroadcastItemResult(target, itemState, exception.Code));
|
||||
errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
|
||||
if (itemState == "Unconfirmed" || stopOnError)
|
||||
{
|
||||
stopped = true;
|
||||
stopReason = exception.Code;
|
||||
unconfirmed = itemState == "Unconfirmed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (WxAgentException exception)
|
||||
{
|
||||
var code = exception.Code.ToString();
|
||||
var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed";
|
||||
items.Add(new BroadcastItemResult(target, itemState, code));
|
||||
errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
|
||||
if (itemState == "Unconfirmed" || stopOnError)
|
||||
{
|
||||
stopped = true;
|
||||
stopReason = code;
|
||||
unconfirmed = itemState == "Unconfirmed";
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed"));
|
||||
stopped = true;
|
||||
stopReason = "ExecutionFailed";
|
||||
errorCode = "ExecutionFailed";
|
||||
unconfirmed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json);
|
||||
return new OperationExecutionResult(details, errorCode, unconfirmed);
|
||||
}
|
||||
|
||||
private static string RequireText(string? value, string name, int maxLength)
|
||||
|
||||
@@ -57,9 +57,13 @@ public sealed class AgentTools(AgentService service)
|
||||
[McpServerTool(Name = "operations_list", ReadOnly = true), Description("List bounded operations owned by the current identity; accountId is a caller-side view filter, not an authorization boundary.")]
|
||||
public Task<CallToolResult> Operations(string? accountId = null, int limit = 50, int offset = 0) => Result(() => Task.FromResult<object>(service.Operations(accountId, limit, offset)));
|
||||
|
||||
[McpServerTool(Name = "operation_submit"), Description("Submit an explicitly confirmed operation. send-text remains disabled until Windows validation is complete.")]
|
||||
[McpServerTool(Name = "operation_submit"), Description("Submit one explicitly confirmed send-text operation. The targetId must be a frozen visible session automation ID.")]
|
||||
public Task<CallToolResult> SubmitOperation(string kind, string accountId, string targetId, string? text, string idempotencyKey, bool confirmed = false, CancellationToken cancellationToken = default) =>
|
||||
Result(() => Task.FromResult<object>(service.SubmitOperation(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken)));
|
||||
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken));
|
||||
|
||||
[McpServerTool(Name = "broadcast_text"), Description("Submit an explicitly confirmed broadcast-text operation for a frozen list of 1..20 visible session automation IDs. Sends sequentially, stops on the first known failure by default, and returns per-target results; replaying the same idempotency key never replays terminal writes.")]
|
||||
public Task<CallToolResult> BroadcastText(string accountId, string[] targets, string text, string idempotencyKey, bool confirmed = false, bool stopOnError = true, CancellationToken cancellationToken = default) =>
|
||||
Result(async () => await service.SubmitOperationAsync(new OperationSubmitRequest("broadcast-text", accountId, null, text, idempotencyKey, confirmed, targets, stopOnError), cancellationToken));
|
||||
|
||||
[McpServerTool(Name = "operation_get", ReadOnly = true), Description("Read one operation owned by the current identity; terminal writes are never replayed.")]
|
||||
public Task<CallToolResult> Operation(string operationId) => Result(() => Task.FromResult<object>(service.Operation(operationId)));
|
||||
|
||||
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace WxAgent.Service;
|
||||
|
||||
public sealed record OperationExecutionResult(string? Details = null, string? ErrorCode = null, bool Unconfirmed = false);
|
||||
|
||||
public sealed class OperationQueue(OperationStore store, ServiceSecurity security) : BackgroundService
|
||||
{
|
||||
private const int QueueCapacity = 100;
|
||||
private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission,
|
||||
Func<CancellationToken, Task> Action, CancellationTokenSource Cancel);
|
||||
Func<CancellationToken, Task<OperationExecutionResult>> Action, CancellationTokenSource Cancel);
|
||||
private readonly Channel<Work> queue = Channel.CreateBounded<Work>(new BoundedChannelOptions(QueueCapacity)
|
||||
{ SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> cancellations = new();
|
||||
@@ -18,7 +20,17 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
private bool stopping;
|
||||
|
||||
public OperationRecord Submit(ServiceIdentity identity, string accountId, AgentCapability capability,
|
||||
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action)
|
||||
string? idempotencyKey, string canonicalParameters, Func<CancellationToken, Task> action) =>
|
||||
SubmitResult(identity, accountId, capability, idempotencyKey, canonicalParameters, null,
|
||||
async cancellationToken =>
|
||||
{
|
||||
await action(cancellationToken).ConfigureAwait(false);
|
||||
return new OperationExecutionResult();
|
||||
});
|
||||
|
||||
public OperationRecord SubmitResult(ServiceIdentity identity, string accountId, AgentCapability capability,
|
||||
string? idempotencyKey, string canonicalParameters, string? initialDetails,
|
||||
Func<CancellationToken, Task<OperationExecutionResult>> action)
|
||||
{
|
||||
security.RequireCurrent(identity, capability.Permission, accountId);
|
||||
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability unavailable.");
|
||||
@@ -30,7 +42,7 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
{
|
||||
if (stopping) throw new ServiceException("Unavailable", 503, "Agent is stopping.");
|
||||
var (record, created) = store.Enqueue(identity.PrincipalId, accountId, capability.Operation,
|
||||
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity);
|
||||
idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity, initialDetails);
|
||||
if (!created) return record;
|
||||
var cancel = new CancellationTokenSource();
|
||||
cancellations[record.Id] = cancel;
|
||||
@@ -92,10 +104,16 @@ public sealed class OperationQueue(OperationStore store, ServiceSecurity securit
|
||||
started = true;
|
||||
}
|
||||
// Never release this slot with WaitAsync: the actual action must have stopped first.
|
||||
await work.Action(budget.Token);
|
||||
var result = await work.Action(budget.Token);
|
||||
if (result.ErrorCode is not null)
|
||||
{
|
||||
store.Transition(work.Record.Id, result.Unconfirmed ? "Unconfirmed" : "Failed",
|
||||
"execution", result.ErrorCode, result.Details);
|
||||
continue;
|
||||
}
|
||||
budget.Token.ThrowIfCancellationRequested();
|
||||
security.RequireCurrent(work.Identity, work.Permission, work.Record.AccountId);
|
||||
store.Transition(work.Record.Id, "Succeeded", "complete");
|
||||
store.Transition(work.Record.Id, "Succeeded", "complete", details: result.Details);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace WxAgent.Service;
|
||||
|
||||
public sealed record OperationRecord(string Id, string PrincipalId, string AccountId, string Capability,
|
||||
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
string? ErrorCode, bool HasSideEffects);
|
||||
string? ErrorCode, bool HasSideEffects, string? Details = null);
|
||||
|
||||
public sealed record OperationSummary(string Id, string AccountId, string Capability,
|
||||
string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
@@ -37,10 +37,12 @@ public sealed class OperationStore : IDisposable
|
||||
stage='restart', error='AgentRestarted' WHERE state='Running';
|
||||
UPDATE operations SET state='Cancelled', stage='restart', error='AgentRestarted' WHERE state='Queued';
|
||||
""");
|
||||
try { Execute("ALTER TABLE operations ADD COLUMN details TEXT"); }
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 1) { }
|
||||
}
|
||||
|
||||
public (OperationRecord Record, bool Created) Enqueue(string principal, string account, string capability,
|
||||
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity)
|
||||
string? idempotency, string digest, bool sideEffects, TimeSpan budget, int capacity, string? details = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
@@ -67,10 +69,10 @@ public sealed class OperationStore : IDisposable
|
||||
throw new ServiceException("QueueFull", 429, "Agent queue is full.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var operation = new OperationRecord(Guid.NewGuid().ToString("N"), principal, account, capability,
|
||||
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects);
|
||||
"Queued", "queued", Guid.NewGuid().ToString("N"), now, now.Add(budget), null, sideEffects, details);
|
||||
using var insert = database.CreateCommand();
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandText = "INSERT INTO operations VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest)";
|
||||
insert.CommandText = "INSERT INTO operations (id,principal,account,capability,state,stage,correlation,created,expires,error,side_effects,idempotency,digest,details) VALUES ($id,$p,$a,$c,'Queued','queued',$correlation,$created,$expires,NULL,$effects,$key,$digest,$details)";
|
||||
insert.Parameters.AddWithValue("$id", operation.Id);
|
||||
insert.Parameters.AddWithValue("$p", principal);
|
||||
insert.Parameters.AddWithValue("$a", account);
|
||||
@@ -81,6 +83,7 @@ public sealed class OperationStore : IDisposable
|
||||
insert.Parameters.AddWithValue("$effects", sideEffects ? 1 : 0);
|
||||
insert.Parameters.AddWithValue("$key", (object?)idempotency ?? DBNull.Value);
|
||||
insert.Parameters.AddWithValue("$digest", digest);
|
||||
insert.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
|
||||
insert.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
return (operation, true);
|
||||
@@ -122,16 +125,17 @@ public sealed class OperationStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Transition(string id, string state, string stage, string? error = null)
|
||||
public void Transition(string id, string state, string stage, string? error = null, string? details = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
using var command = database.CreateCommand();
|
||||
command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error WHERE id=$id AND state IN ('Queued','Running')";
|
||||
command.CommandText = "UPDATE operations SET state=$state,stage=$stage,error=$error,details=COALESCE($details,details) WHERE id=$id AND state IN ('Queued','Running')";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
command.Parameters.AddWithValue("$state", state);
|
||||
command.Parameters.AddWithValue("$stage", stage);
|
||||
command.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$details", (object?)details ?? DBNull.Value);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,8 @@ public sealed class OperationStore : IDisposable
|
||||
private static OperationRecord Read(SqliteDataReader reader) => new(reader.GetString(0), reader.GetString(1), reader.GetString(2),
|
||||
reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6),
|
||||
DateTimeOffset.Parse(reader.GetString(7), System.Globalization.CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0);
|
||||
DateTimeOffset.Parse(reader.GetString(8), System.Globalization.CultureInfo.InvariantCulture), reader.IsDBNull(9) ? null : reader.GetString(9), reader.GetInt64(10) != 0,
|
||||
reader.IsDBNull(13) ? null : reader.GetString(13));
|
||||
|
||||
private void Execute(string sql)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,20 @@ public sealed record AccountInfo(string AccountId, string? DisplayName, string?
|
||||
public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent);
|
||||
public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content);
|
||||
public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null);
|
||||
public sealed record OperationSubmitRequest(string? Kind, string? AccountId, string? TargetId, string? Text, string? IdempotencyKey, bool Confirmed);
|
||||
public sealed record OperationSubmitRequest(
|
||||
string? Kind,
|
||||
string? AccountId,
|
||||
string? TargetId,
|
||||
string? Text,
|
||||
string? IdempotencyKey,
|
||||
bool Confirmed,
|
||||
IReadOnlyList<string>? Targets = null,
|
||||
bool StopOnError = true);
|
||||
|
||||
public sealed record BroadcastItemResult(string TargetId, string State, string? ErrorCode);
|
||||
|
||||
public sealed record BroadcastResult(IReadOnlyList<string> TargetIds, IReadOnlyList<BroadcastItemResult> Items,
|
||||
bool Stopped, string? StopReason);
|
||||
|
||||
public sealed class ReadOnlyRequest
|
||||
{
|
||||
|
||||
@@ -137,7 +137,7 @@ public static class ServiceHost
|
||||
return Results.Ok(await service.UploadAsync(form.Files[0], ct));
|
||||
});
|
||||
app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream"));
|
||||
app.MapPost("/api/v1/operations", (OperationSubmitRequest request, AgentService service, CancellationToken ct) => service.SubmitOperation(request, ct));
|
||||
app.MapPost("/api/v1/operations", async (OperationSubmitRequest request, AgentService service, CancellationToken ct) => await service.SubmitOperationAsync(request, ct));
|
||||
app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0));
|
||||
app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id));
|
||||
app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id));
|
||||
|
||||
@@ -22,6 +22,9 @@ public sealed class ServiceOptions
|
||||
public RemoteAgentOptions? Remote { get; init; }
|
||||
public ReportingConfig Reporting { get; init; } = new();
|
||||
public string? RemoteConfigurationFile { get; init; }
|
||||
// Explicitly opt-in for a single, user-authorized Windows validation session.
|
||||
// Production deployments remain read-only unless this local gate is enabled.
|
||||
public bool EnableValidationOperations { get; init; }
|
||||
|
||||
// Kept only so older service.json files can be loaded and rewritten by the tray.
|
||||
[JsonIgnore]
|
||||
|
||||
@@ -136,7 +136,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
};
|
||||
Directory.CreateDirectory(initial.DataDirectory);
|
||||
WriteJson(configPath, initial);
|
||||
WriteCredentials(initial.CredentialFile, initialToken);
|
||||
WriteCredentials(initial.CredentialFile, initialToken, initial.EnableValidationOperations);
|
||||
options = initial;
|
||||
return true;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
if (changed) WriteJson(configPath, normalized);
|
||||
Directory.CreateDirectory(normalized.DataDirectory);
|
||||
// Always rewrite one canonical record so old/malformed/multi-record files self-heal on tray startup.
|
||||
WriteCredentials(normalized.CredentialFile, token);
|
||||
WriteCredentials(normalized.CredentialFile, token, normalized.EnableValidationOperations);
|
||||
options = normalized;
|
||||
return changed;
|
||||
}
|
||||
@@ -177,7 +177,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
DataDirectory = dataDirectory,
|
||||
Remote = source.Remote,
|
||||
Reporting = source.Reporting,
|
||||
RemoteConfigurationFile = source.RemoteConfigurationFile
|
||||
RemoteConfigurationFile = source.RemoteConfigurationFile,
|
||||
EnableValidationOperations = source.EnableValidationOperations
|
||||
};
|
||||
|
||||
private ServiceOptions ReadOptions() =>
|
||||
@@ -190,7 +191,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
var loaded = ReadOptions();
|
||||
loaded.Validate();
|
||||
options = loaded;
|
||||
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded)), logPath: GetLogPath());
|
||||
service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded), loaded), logPath: GetLogPath());
|
||||
try
|
||||
{
|
||||
await service.StartAsync();
|
||||
@@ -283,7 +284,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
EnsureConfiguration();
|
||||
if (ServiceSettingsEditor.Show(options!) is not { } edited) return;
|
||||
WriteCredentials(edited.CredentialFile, edited.AccessToken!);
|
||||
WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations);
|
||||
WriteJson(configPath, edited);
|
||||
_ = ReloadAsync();
|
||||
}
|
||||
@@ -329,12 +330,15 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private static void WriteCredentials(string path, string token)
|
||||
private static void WriteCredentials(string path, string token, bool includeValidationWrite)
|
||||
{
|
||||
var temporary = path + ".tmp";
|
||||
var permissions = includeValidationWrite
|
||||
? new[] { "read", "content", "write", "manage" }
|
||||
: new[] { "read", "content", "manage" };
|
||||
WriteJson(temporary, new[]
|
||||
{
|
||||
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], [])
|
||||
new ServiceCredential("local-admin", ServiceOptions.HashToken(token), permissions, [])
|
||||
});
|
||||
File.Move(temporary, path, true);
|
||||
}
|
||||
|
||||
@@ -500,7 +500,9 @@ public static partial class WechatChatClient
|
||||
var direct = FindByAutomationId(main, $"session_item_{session}");
|
||||
if (direct is not null)
|
||||
{
|
||||
direct.Click();
|
||||
// Use the same visible-center click path as the rest of the UI executor;
|
||||
// FlaUI's Click() can report success without changing WeChat's virtualized chat pane.
|
||||
ClickCenter(direct);
|
||||
await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
@@ -509,7 +511,7 @@ public static partial class WechatChatClient
|
||||
|
||||
private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 25; attempt++)
|
||||
for (var attempt = 0; attempt < 100; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (IsListeningSession(main, session, independent: false)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class BroadcastOperationTests
|
||||
{
|
||||
private sealed class Backend(bool failSecond = false) : IAgentBackend
|
||||
{
|
||||
public List<string> SentTargets { get; } = [];
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, []),
|
||||
new("broadcast-text", true, false, true, true, true, "write", true, 30, null, []),
|
||||
new("send-text", true, false, true, true, true, "write", false, 30, null, [])
|
||||
];
|
||||
|
||||
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { ok = true });
|
||||
|
||||
public Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SessionInfo>>([
|
||||
new("A", "session-a", false),
|
||||
new("B", "session-b", false),
|
||||
new("C", "session-c", false)
|
||||
]);
|
||||
|
||||
public Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (failSecond && targetId == "session-b")
|
||||
throw new ServiceException("TargetUnavailable", 409, "Target is no longer available.");
|
||||
SentTargets.Add(targetId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BroadcastFreezesTargetsRunsInOrderAndReplaysIdempotently()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var token = new string('D', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
|
||||
}));
|
||||
var backend = new Backend();
|
||||
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
|
||||
try
|
||||
{
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
client.BaseAddress = new Uri("http://localhost:5088");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var request = new
|
||||
{
|
||||
kind = "broadcast-text",
|
||||
accountId = "account-1",
|
||||
targets = new[] { "session-a", "session-a", "session-b" },
|
||||
text = "broadcast-test",
|
||||
idempotencyKey = "broadcast-1",
|
||||
confirmed = true,
|
||||
stopOnError = true
|
||||
};
|
||||
|
||||
var submitted = await client.PostAsJsonAsync("/api/v1/operations", request);
|
||||
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
|
||||
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var operationId = queued.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(operationId));
|
||||
var completed = await WaitForTerminalAsync(client, operationId!);
|
||||
Assert.Equal("Succeeded", completed.GetProperty("state").GetString());
|
||||
Assert.Equal(["session-a", "session-b"], backend.SentTargets);
|
||||
|
||||
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
|
||||
Assert.Equal(["session-a", "session-b"], details.GetProperty("targetIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
||||
Assert.Equal(["session-a", "session-b"], details.GetProperty("items").EnumerateArray().Select(x => x.GetProperty("targetId").GetString()!).ToArray());
|
||||
Assert.All(details.GetProperty("items").EnumerateArray(), item => Assert.Equal("Succeeded", item.GetProperty("state").GetString()));
|
||||
|
||||
var replay = await client.PostAsJsonAsync("/api/v1/operations", request);
|
||||
var replayRecord = await replay.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(operationId, replayRecord.GetProperty("id").GetString());
|
||||
Assert.Equal(2, backend.SentTargets.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BroadcastStopsAfterKnownTargetFailureAndReportsPerItemResults()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var token = new string('E', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
|
||||
}));
|
||||
var backend = new Backend(failSecond: true);
|
||||
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
|
||||
try
|
||||
{
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
client.BaseAddress = new Uri("http://localhost:5088");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var submitted = await client.PostAsJsonAsync("/api/v1/operations", new
|
||||
{
|
||||
kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a", "session-b", "session-c" },
|
||||
text = "broadcast-stop-test", idempotencyKey = "broadcast-stop-1", confirmed = true, stopOnError = true
|
||||
});
|
||||
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!);
|
||||
Assert.Equal("Failed", completed.GetProperty("state").GetString());
|
||||
Assert.Equal(["session-a"], backend.SentTargets);
|
||||
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
|
||||
Assert.True(details.GetProperty("stopped").GetBoolean());
|
||||
Assert.Equal(2, details.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal("Failed", details.GetProperty("items")[1].GetProperty("state").GetString());
|
||||
Assert.Equal("TargetUnavailable", details.GetProperty("items")[1].GetProperty("errorCode").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var response = await client.GetAsync($"/api/v1/operations/{operationId}");
|
||||
var record = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var state = record.GetProperty("state").GetString();
|
||||
if (state is "Succeeded" or "Failed" or "Unconfirmed" or "Cancelled" or "Expired") return record;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
throw new TimeoutException("Broadcast operation did not reach a terminal state.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user