mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
+167
-1
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -36,6 +37,8 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
"/v1/resources",
|
||||
"/openapi.yaml",
|
||||
"/admin/schedules",
|
||||
"/admin/translation/tasks",
|
||||
"/admin/translation/handoff",
|
||||
},
|
||||
Controls: []string{
|
||||
"/admin/control/reload",
|
||||
@@ -50,6 +53,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
"/admin/control/schedule-remove",
|
||||
"/admin/control/schedule-run",
|
||||
"/admin/control/translation-task-update",
|
||||
"/admin/control/translation-worker-run",
|
||||
"/admin/control/translation-proofread",
|
||||
},
|
||||
}
|
||||
@@ -82,6 +86,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminTranslationTaskUpdate(w, r)
|
||||
return
|
||||
}
|
||||
if action == "translation-worker-run" {
|
||||
s.handleAdminTranslationWorkerRun(w, r)
|
||||
return
|
||||
}
|
||||
if action == "translation-proofread" {
|
||||
s.handleAdminTranslationProofread(w, r)
|
||||
return
|
||||
@@ -187,6 +195,34 @@ func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTranslationWorkerRun(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.TranslationWorkerRunParams
|
||||
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||
return
|
||||
}
|
||||
if err := validateTranslationWorkerRunParams(params); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := backend.TranslationWorkerRun(r.Context(), params)
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "translation-worker-run", err)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||
Service: "bat-api",
|
||||
Action: "translation-worker-run",
|
||||
RPCMethod: "translation.worker.run",
|
||||
Status: "accepted",
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http.Request) {
|
||||
backend, ok := s.backend.(TranslationBackend)
|
||||
if !ok || backend == nil {
|
||||
@@ -207,6 +243,63 @@ func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http.
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTranslationTasks(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")
|
||||
return
|
||||
}
|
||||
if !s.requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(TranslationBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable")
|
||||
return
|
||||
}
|
||||
params, err := translationTaskListParams(r)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_query", err.Error())
|
||||
return
|
||||
}
|
||||
result, err := backend.TranslationTasks(r.Context(), params)
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "translation-tasks", err)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTranslationHandoff(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")
|
||||
return
|
||||
}
|
||||
if !s.requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
backend, ok := s.backend.(TranslationBackend)
|
||||
if !ok || backend == nil {
|
||||
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable")
|
||||
return
|
||||
}
|
||||
result, err := backend.TranslationHandoff(r.Context())
|
||||
if err != nil {
|
||||
s.writeControlBackendError(w, "translation-handoff", err)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
writeNoStoreJSON(w, http.StatusOK, 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")
|
||||
@@ -254,6 +347,63 @@ func scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error)
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListParams, error) {
|
||||
query := r.URL.Query()
|
||||
params := backendrpc.TranslationTaskListParams{
|
||||
TaskID: strings.TrimSpace(query.Get("task_id")),
|
||||
ReleaseID: firstTrimmedQuery(query, "release_id", "official_release_id"),
|
||||
Destination: strings.TrimSpace(query.Get("destination")),
|
||||
PathPattern: strings.TrimSpace(query.Get("path_pattern")),
|
||||
ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")),
|
||||
Status: firstTrimmedQuery(query, "status", "task_status"),
|
||||
WorkerStatus: strings.TrimSpace(query.Get("worker_status")),
|
||||
ParseStatus: strings.TrimSpace(query.Get("parse_status")),
|
||||
Format: firstTrimmedQuery(query, "format", "text_unit_format"),
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
|
||||
offset, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return backendrpc.TranslationTaskListParams{}, errors.New("offset must be a non-negative integer")
|
||||
}
|
||||
params.Offset = &offset
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
|
||||
limit, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || limit == 0 || limit > 1000 {
|
||||
return backendrpc.TranslationTaskListParams{}, errors.New("limit must be in 1..=1000")
|
||||
}
|
||||
params.Limit = &limit
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("has_reason")); raw != "" {
|
||||
hasReason, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return backendrpc.TranslationTaskListParams{}, errors.New("has_reason must be a boolean")
|
||||
}
|
||||
params.HasReason = &hasReason
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("has_failure_reason")); raw != "" {
|
||||
hasFailureReason, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return backendrpc.TranslationTaskListParams{}, errors.New("has_failure_reason must be a boolean")
|
||||
}
|
||||
params.HasFailureReason = &hasFailureReason
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func firstTrimmedQuery(query url.Values, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
values := query[key]
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if value := strings.TrimSpace(values[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) {
|
||||
backend, ok := s.backend.(ScheduleBackend)
|
||||
if !ok || backend == nil {
|
||||
@@ -325,7 +475,23 @@ func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any)
|
||||
}
|
||||
|
||||
func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation task request")
|
||||
return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request")
|
||||
}
|
||||
|
||||
func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error {
|
||||
if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) {
|
||||
return errors.New("translation worker concurrency must be in 1..=256")
|
||||
}
|
||||
if params.MaxAttempts != nil && *params.MaxAttempts == 0 {
|
||||
return errors.New("translation worker max_attempts must be greater than 0")
|
||||
}
|
||||
if params.LeaseSeconds != nil && *params.LeaseSeconds == 0 {
|
||||
return errors.New("translation worker lease_seconds must be greater than 0")
|
||||
}
|
||||
if params.MaxTasks != nil && *params.MaxTasks == 0 {
|
||||
return errors.New("translation worker max_tasks must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool {
|
||||
|
||||
+123
-1
@@ -588,7 +588,8 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit in
|
||||
|
||||
type controlBackend struct {
|
||||
*fakeBackend
|
||||
calls []string
|
||||
calls []string
|
||||
translationTaskListParams []backendrpc.TranslationTaskListParams
|
||||
}
|
||||
|
||||
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
||||
@@ -631,6 +632,38 @@ func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backe
|
||||
return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) {
|
||||
b.calls = append(b.calls, "translation.tasks")
|
||||
b.translationTaskListParams = append(b.translationTaskListParams, params)
|
||||
return json.RawMessage(`{"tasks":[{"task_id":"textunit/v-current/Scenario","worker_status":"failed","failure_reason":"provider rejected payload"}]}`), nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) TranslationHandoff(ctx context.Context) (json.RawMessage, error) {
|
||||
b.calls = append(b.calls, "translation.handoff")
|
||||
return json.RawMessage(`{"jobs":[],"provider_runs":[]}`), nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) {
|
||||
b.calls = append(b.calls, "translation.worker.run")
|
||||
concurrency := uint64(8)
|
||||
if params.Concurrency != nil {
|
||||
concurrency = *params.Concurrency
|
||||
}
|
||||
return &backendrpc.TranslationWorkerRunResult{
|
||||
TaskID: "task-translation-worker-1",
|
||||
Kind: "translation.worker.run",
|
||||
Worker: backendrpc.TranslationWorkerConfig{
|
||||
Provider: params.Provider,
|
||||
FixturePath: params.FixturePath,
|
||||
Concurrency: concurrency,
|
||||
MaxAttempts: 3,
|
||||
LeaseSeconds: 300,
|
||||
RetryBackoffSeconds: 5,
|
||||
WorkerID: params.WorkerID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
||||
b.calls = append(b.calls, "translation.proofread")
|
||||
return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil
|
||||
@@ -743,6 +776,81 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AuthToken = "translation-token"
|
||||
if err := cfg.Normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &controlBackend{fakeBackend: &fakeBackend{}}
|
||||
s := NewServer(cfg, backend, nil)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/admin/translation/tasks?offset=10&limit=25&task_id=textunit%2Fv-current%2FScenario&release_id=v-current&destination=MediaResources%2FGameData%2FScenario.zip&path_pattern=Scenario&archive_entry=ScenarioExcelTable.json&status=queued_offline&worker_status=failed&parse_status=ok&format=json&has_reason=true&has_failure_reason=false",
|
||||
nil,
|
||||
)
|
||||
request.Header.Set("Authorization", "Bearer translation-token")
|
||||
recorder := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("tasks status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !json.Valid(recorder.Body.Bytes()) || !strings.Contains(recorder.Body.String(), "failure_reason") {
|
||||
t.Fatalf("tasks body=%s", recorder.Body.String())
|
||||
}
|
||||
if len(backend.translationTaskListParams) != 1 {
|
||||
t.Fatalf("translation task query params=%#v", backend.translationTaskListParams)
|
||||
}
|
||||
params := backend.translationTaskListParams[0]
|
||||
if params.Offset == nil || *params.Offset != 10 ||
|
||||
params.Limit == nil || *params.Limit != 25 ||
|
||||
params.TaskID != "textunit/v-current/Scenario" ||
|
||||
params.ReleaseID != "v-current" ||
|
||||
params.Destination != "MediaResources/GameData/Scenario.zip" ||
|
||||
params.PathPattern != "Scenario" ||
|
||||
params.ArchiveEntry != "ScenarioExcelTable.json" ||
|
||||
params.Status != "queued_offline" ||
|
||||
params.WorkerStatus != "failed" ||
|
||||
params.ParseStatus != "ok" ||
|
||||
params.Format != "json" ||
|
||||
params.HasReason == nil || !*params.HasReason ||
|
||||
params.HasFailureReason == nil || *params.HasFailureReason {
|
||||
t.Fatalf("params=%#v", params)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/admin/translation/handoff", nil)
|
||||
request.Header.Set("Authorization", "Bearer translation-token")
|
||||
recorder = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("handoff status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "provider_runs") {
|
||||
t.Fatalf("handoff body=%s", recorder.Body.String())
|
||||
}
|
||||
if len(backend.calls) < 2 ||
|
||||
backend.calls[len(backend.calls)-2] != "translation.tasks" ||
|
||||
backend.calls[len(backend.calls)-1] != "translation.handoff" {
|
||||
t.Fatalf("calls=%v", backend.calls)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
|
||||
request.Header.Set("Authorization", "Bearer translation-token")
|
||||
recorder = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid task query status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
||||
root := fixtureRoot(t)
|
||||
bytes := uint64(20)
|
||||
@@ -1281,6 +1389,11 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
|
||||
if len(admin.Controls) == 0 || admin.Controls[0] != "/admin/control/reload" {
|
||||
t.Fatalf("admin controls=%v", admin.Controls)
|
||||
}
|
||||
links := strings.Join(admin.Links, "\n")
|
||||
if !strings.Contains(links, "/admin/translation/tasks") ||
|
||||
!strings.Contains(links, "/admin/translation/handoff") {
|
||||
t.Fatalf("admin links=%v", admin.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
@@ -1305,6 +1418,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
{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"},
|
||||
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
|
||||
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -1336,6 +1450,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`))
|
||||
request.Header.Set("Authorization", "Bearer control-token")
|
||||
recorder = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
|
||||
|
||||
+103
-1
@@ -139,6 +139,80 @@ paths:
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat schedule backend is unavailable.
|
||||
/admin/translation/tasks:
|
||||
get:
|
||||
summary: List Rust-owned translation task worker status
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
- name: task_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: release_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: destination
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: archive_entry
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: worker_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: parse_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: format
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: has_reason
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: has_failure_reason
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Current translation task JSON report from Rust bat.
|
||||
"400":
|
||||
description: Invalid translation task query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat translation backend is unavailable.
|
||||
/admin/translation/handoff:
|
||||
get:
|
||||
summary: Read Rust-owned translation handoff state
|
||||
responses:
|
||||
"200":
|
||||
description: Current translation handoff JSON report from Rust bat.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat translation backend is unavailable.
|
||||
/admin/control/{action}:
|
||||
post:
|
||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||
@@ -148,7 +222,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-proofread]
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -199,6 +273,34 @@ paths:
|
||||
type: string
|
||||
provider_run_id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
enum: [mock, crowdin]
|
||||
fixture_path:
|
||||
type: string
|
||||
concurrency:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
maximum: 256
|
||||
max_attempts:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
lease_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
retry_backoff_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
max_tasks:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
worker_id:
|
||||
type: string
|
||||
responses:
|
||||
"202":
|
||||
description: Rust bat accepted the control request.
|
||||
|
||||
@@ -54,6 +54,9 @@ type ScheduleBackend interface {
|
||||
// by the dashboard. It does not create arbitrary translation jobs.
|
||||
type TranslationBackend interface {
|
||||
TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error)
|
||||
TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error)
|
||||
TranslationHandoff(ctx context.Context) (json.RawMessage, error)
|
||||
TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error)
|
||||
TranslationProofread(ctx context.Context) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
@@ -117,6 +120,18 @@ func (r RPCClient) TranslationTaskUpdate(ctx context.Context, params backendrpc.
|
||||
return r.Client.TranslationTaskUpdate(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) {
|
||||
return r.Client.TranslationTasks(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) TranslationHandoff(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.TranslationHandoff(ctx)
|
||||
}
|
||||
|
||||
func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) {
|
||||
return r.Client.TranslationWorkerRun(ctx, params)
|
||||
}
|
||||
|
||||
func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.TranslationProofread(ctx)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
||||
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
||||
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
|
||||
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
|
||||
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
|
||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||
@@ -127,6 +129,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"/openapi.yaml",
|
||||
"/admin/",
|
||||
"/admin/schedules",
|
||||
"/admin/translation/tasks",
|
||||
"/admin/translation/handoff",
|
||||
"/admin/control/{action}",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -273,6 +273,58 @@ type TranslationTaskUpdateParams struct {
|
||||
ProviderRunID string `json:"provider_run_id,omitempty"`
|
||||
}
|
||||
|
||||
// TranslationTaskListParams filters the Rust-owned translation task queue.
|
||||
// The result shape is intentionally left as Rust JSON so Go does not duplicate
|
||||
// the translation task schema.
|
||||
type TranslationTaskListParams struct {
|
||||
Offset *uint64 `json:"offset,omitempty"`
|
||||
Limit *uint64 `json:"limit,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
ReleaseID string `json:"release_id,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
PathPattern string `json:"path_pattern,omitempty"`
|
||||
ArchiveEntry string `json:"archive_entry,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
WorkerStatus string `json:"worker_status,omitempty"`
|
||||
ParseStatus string `json:"parse_status,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
HasReason *bool `json:"has_reason,omitempty"`
|
||||
HasFailureReason *bool `json:"has_failure_reason,omitempty"`
|
||||
}
|
||||
|
||||
// TranslationWorkerRunParams starts one Rust-owned provider worker task.
|
||||
// Pointer numeric fields preserve explicit zeroes so Rust can reject invalid
|
||||
// dashboard input instead of receiving omitted defaults.
|
||||
type TranslationWorkerRunParams struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
FixturePath string `json:"fixture_path,omitempty"`
|
||||
Concurrency *uint64 `json:"concurrency,omitempty"`
|
||||
MaxAttempts *uint64 `json:"max_attempts,omitempty"`
|
||||
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
|
||||
RetryBackoffSeconds *uint64 `json:"retry_backoff_seconds,omitempty"`
|
||||
MaxTasks *uint64 `json:"max_tasks,omitempty"`
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
}
|
||||
|
||||
// TranslationWorkerConfig mirrors the accepted worker config returned by Rust.
|
||||
type TranslationWorkerConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
FixturePath string `json:"fixture_path,omitempty"`
|
||||
Concurrency uint64 `json:"concurrency"`
|
||||
MaxAttempts uint64 `json:"max_attempts"`
|
||||
LeaseSeconds uint64 `json:"lease_seconds"`
|
||||
RetryBackoffSeconds uint64 `json:"retry_backoff_seconds"`
|
||||
MaxTasks *uint64 `json:"max_tasks,omitempty"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
}
|
||||
|
||||
// TranslationWorkerRunResult is returned when translation.worker.run is queued.
|
||||
type TranslationWorkerRunResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Kind string `json:"kind"`
|
||||
Worker TranslationWorkerConfig `json:"worker"`
|
||||
}
|
||||
|
||||
// TranslationProofread marks the current localized workflow as manual
|
||||
// proofreading. Rust owns the persisted localized state.
|
||||
func (c *Client) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
||||
@@ -502,6 +554,20 @@ func (c *Client) TranslationTaskUpdate(ctx context.Context, params TranslationTa
|
||||
return c.rawData(ctx, "translation.task.update", params)
|
||||
}
|
||||
|
||||
func (c *Client) TranslationTasks(ctx context.Context, params TranslationTaskListParams) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "translation.tasks", params)
|
||||
}
|
||||
|
||||
func (c *Client) TranslationHandoff(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "translation.handoff", nil)
|
||||
}
|
||||
|
||||
func (c *Client) TranslationWorkerRun(ctx context.Context, params TranslationWorkerRunParams) (*TranslationWorkerRunResult, error) {
|
||||
var out TranslationWorkerRunResult
|
||||
_, err := c.Call(ctx, "translation.worker.run", params, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "catalog.status", nil)
|
||||
}
|
||||
|
||||
@@ -336,6 +336,94 @@ func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationTasksSendsQueryParams(t *testing.T) {
|
||||
offset := uint64(10)
|
||||
limit := uint64(25)
|
||||
hasReason := true
|
||||
hasFailureReason := false
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "translation.tasks" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params TranslationTaskListParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.Offset == nil || *params.Offset != offset ||
|
||||
params.Limit == nil || *params.Limit != limit ||
|
||||
params.TaskID != "textunit/v-current/Scenario" ||
|
||||
params.ReleaseID != "v-current" ||
|
||||
params.Destination != "MediaResources/GameData/Scenario.zip" ||
|
||||
params.PathPattern != "Scenario" ||
|
||||
params.ArchiveEntry != "ScenarioExcelTable.json" ||
|
||||
params.Status != "queued_offline" ||
|
||||
params.WorkerStatus != "failed" ||
|
||||
params.ParseStatus != "ok" ||
|
||||
params.Format != "json" ||
|
||||
params.HasReason == nil || *params.HasReason != hasReason ||
|
||||
params.HasFailureReason == nil || *params.HasFailureReason != hasFailureReason {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-translation-tasks",
|
||||
Data: map[string]any{"tasks": []any{}},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
raw, err := client.TranslationTasks(context.Background(), TranslationTaskListParams{
|
||||
Offset: &offset,
|
||||
Limit: &limit,
|
||||
TaskID: "textunit/v-current/Scenario",
|
||||
ReleaseID: "v-current",
|
||||
Destination: "MediaResources/GameData/Scenario.zip",
|
||||
PathPattern: "Scenario",
|
||||
ArchiveEntry: "ScenarioExcelTable.json",
|
||||
Status: "queued_offline",
|
||||
WorkerStatus: "failed",
|
||||
ParseStatus: "ok",
|
||||
Format: "json",
|
||||
HasReason: &hasReason,
|
||||
HasFailureReason: &hasFailureReason,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TranslationTasks error: %v", err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationHandoffUsesRustMethod(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "translation.handoff" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
if string(req.Params) != "null" {
|
||||
t.Fatalf("params = %s, want null", req.Params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-translation-handoff",
|
||||
Data: map[string]any{"jobs": []any{}},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
raw, err := client.TranslationHandoff(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("TranslationHandoff error: %v", err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "translation.task.update" {
|
||||
@@ -375,6 +463,75 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) {
|
||||
var concurrency uint64 = 8
|
||||
var maxAttempts uint64 = 4
|
||||
var leaseSeconds uint64 = 60
|
||||
var retryBackoff uint64 = 0
|
||||
var maxTasks uint64 = 2
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "translation.worker.run" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params TranslationWorkerRunParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.Provider != "mock" ||
|
||||
params.FixturePath != "/tmp/mock-provider.json" ||
|
||||
params.Concurrency == nil || *params.Concurrency != concurrency ||
|
||||
params.MaxAttempts == nil || *params.MaxAttempts != maxAttempts ||
|
||||
params.LeaseSeconds == nil || *params.LeaseSeconds != leaseSeconds ||
|
||||
params.RetryBackoffSeconds == nil || *params.RetryBackoffSeconds != retryBackoff ||
|
||||
params.MaxTasks == nil || *params.MaxTasks != maxTasks ||
|
||||
params.WorkerID != "dashboard-worker" {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "accepted",
|
||||
RequestID: "req-test-translation-worker",
|
||||
Data: map[string]any{
|
||||
"task_id": "task-worker-1",
|
||||
"kind": "translation.worker.run",
|
||||
"worker": map[string]any{
|
||||
"provider": "mock",
|
||||
"fixture_path": "/tmp/mock-provider.json",
|
||||
"concurrency": concurrency,
|
||||
"max_attempts": maxAttempts,
|
||||
"lease_seconds": leaseSeconds,
|
||||
"retry_backoff_seconds": retryBackoff,
|
||||
"max_tasks": maxTasks,
|
||||
"worker_id": "dashboard-worker",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
result, err := client.TranslationWorkerRun(context.Background(), TranslationWorkerRunParams{
|
||||
Provider: "mock",
|
||||
FixturePath: "/tmp/mock-provider.json",
|
||||
Concurrency: &concurrency,
|
||||
MaxAttempts: &maxAttempts,
|
||||
LeaseSeconds: &leaseSeconds,
|
||||
RetryBackoffSeconds: &retryBackoff,
|
||||
MaxTasks: &maxTasks,
|
||||
WorkerID: "dashboard-worker",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TranslationWorkerRun error: %v", err)
|
||||
}
|
||||
if result.TaskID != "task-worker-1" ||
|
||||
result.Kind != "translation.worker.run" ||
|
||||
result.Worker.Concurrency != concurrency ||
|
||||
result.Worker.RetryBackoffSeconds != retryBackoff ||
|
||||
result.Worker.MaxTasks == nil || *result.Worker.MaxTasks != maxTasks {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationProofreadUsesRustMethod(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "translation.proofread" {
|
||||
|
||||
Reference in New Issue
Block a user