feat(bat): 暴露翻译任务状态控制

Refs #43
This commit is contained in:
2026-08-12 09:11:09 +08:00
parent 1933d6acb0
commit 974a6e18c3
17 changed files with 341 additions and 13 deletions
+43 -2
View File
@@ -49,6 +49,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/schedule-update",
"/admin/control/schedule-remove",
"/admin/control/schedule-run",
"/admin/control/translation-task-update",
},
}
if r.Method == http.MethodHead {
@@ -76,6 +77,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminScheduleControl(w, r, action)
return
}
if action == "translation-task-update" {
s.handleAdminTranslationTaskUpdate(w, r)
return
}
request, ok := decodeAdminControlRequest(w, r)
if !ok {
return
@@ -149,6 +154,34 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(TranslationBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable")
return
}
var params backendrpc.TranslationTaskUpdateParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status")
return
}
result, err := backend.TranslationTaskUpdate(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-task-update", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "translation-task-update",
RPCMethod: "translation.task.update",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -263,6 +296,14 @@ func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool
}
func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool {
return decodeAdminJSON(w, r, target, "invalid_schedule_params", "schedule request")
}
func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target any) bool {
return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation task request")
}
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool {
if r.Body == nil {
return true
}
@@ -272,11 +313,11 @@ func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any)
if errors.Is(err, io.EOF) {
return true
}
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must be a JSON object")
writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must be a JSON object")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must contain exactly one JSON object")
writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must contain exactly one JSON object")
return false
}
return true
+14
View File
@@ -588,6 +588,11 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
}
func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.task.update")
return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil
}
type scheduleBackend struct {
*controlBackend
scheduleCalls []string
@@ -1150,6 +1155,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -1172,6 +1178,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
}
})
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
+9 -1
View File
@@ -148,7 +148,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update]
requestBody:
required: false
content:
@@ -191,6 +191,14 @@ paths:
type: boolean
enabled:
type: boolean
task_id:
type: string
status:
type: string
failure_reason:
type: string
provider_run_id:
type: string
responses:
"202":
description: Rust bat accepted the control request.
+9
View File
@@ -49,6 +49,12 @@ type ScheduleBackend interface {
ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error)
}
// TranslationBackend exposes the narrow provider-worker state mutation used
// by the dashboard. It does not create arbitrary translation jobs.
type TranslationBackend interface {
TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
@@ -105,6 +111,9 @@ func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.Schedul
func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) {
return r.Client.ScheduleRun(ctx, params)
}
func (r RPCClient) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
return r.Client.TranslationTaskUpdate(ctx, params)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx)
}
+13
View File
@@ -264,6 +264,15 @@ type ScheduleRunParams struct {
MaxRuns *uint64 `json:"max_runs,omitempty"`
}
// TranslationTaskUpdateParams is used by translation.task.update to persist
// provider worker state for one task in the current official release.
type TranslationTaskUpdateParams struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
FailureReason string `json:"failure_reason,omitempty"`
ProviderRunID string `json:"provider_run_id,omitempty"`
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
@@ -483,6 +492,10 @@ func (c *Client) ScheduleRun(ctx context.Context, params ScheduleRunParams) (jso
return c.rawData(ctx, "schedule.run", params)
}
func (c *Client) TranslationTaskUpdate(ctx context.Context, params TranslationTaskUpdateParams) (json.RawMessage, error) {
return c.rawData(ctx, "translation.task.update", params)
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
+39
View File
@@ -336,6 +336,45 @@ func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) {
}
}
func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "translation.task.update" {
t.Fatalf("method = %s", req.Method)
}
var params TranslationTaskUpdateParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.TaskID != "textunit/v-current/Scenario" ||
params.Status != "failed" ||
params.FailureReason != "provider rejected payload" ||
params.ProviderRunID != "provider-run-1" {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-translation-update",
Data: map[string]any{"task_status": "failed"},
},
}
})
raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{
TaskID: "textunit/v-current/Scenario",
Status: "failed",
FailureReason: "provider rejected payload",
ProviderRunID: "provider-run-1",
})
if err != nil {
t.Fatalf("TranslationTaskUpdate error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {