Files
BlueArchiveToolkit/internal/backendrpc/client.go
T
nyaKazuha 72c1a879a3
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(bat): 补全工作流命令与人工校对状态
Refs #43
2026-08-19 21:33:33 +08:00

582 lines
19 KiB
Go

// Package backendrpc is the Go client for the local Rust Resource Backend.
//
// The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0.
// It is the default Go integration path for bat-api; do not shell out to the
// bat binary for normal service calls.
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sync/atomic"
"time"
)
const jsonRPCVersion = "2.0"
// Client calls the local Rust daemon through a Unix domain socket.
type Client struct {
SocketPath string
Timeout time.Duration
// DialContext exists for tests and alternative local transports.
DialContext func(ctx context.Context, network string, address string) (net.Conn, error)
seq atomic.Uint64
}
// New returns a client for the given bat.sock path.
func New(socketPath string) *Client {
return &Client{SocketPath: socketPath, Timeout: 30 * time.Second}
}
// APIError mirrors the Rust ApiError envelope payload.
type APIError struct {
Code string `json:"code"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Location string `json:"location"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
func (e *APIError) Error() string {
if e == nil {
return "<nil api error>"
}
if e.Code == "" {
return e.Message
}
return fmt.Sprintf("%s %s", e.Code, e.Message)
}
// JSONRPCError is a transport-level JSON-RPC error.
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *JSONRPCError) Error() string {
if e == nil {
return "<nil json-rpc error>"
}
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
}
// Envelope is the application-level payload carried in JSON-RPC result.
type Envelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data json.RawMessage `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
// Call invokes method and decodes the envelope data into out when out is non-nil.
func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) {
envelope, err := c.callEnvelope(ctx, method, params)
if err != nil {
return nil, err
}
if !envelope.OK {
if envelope.Error != nil {
return envelope, envelope.Error
}
return envelope, errors.New("backend rpc returned ok=false without error payload")
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return envelope, fmt.Errorf("decode %s response data: %w", method, err)
}
}
return envelope, nil
}
func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) {
if c.SocketPath == "" {
return nil, errors.New("backend rpc socket path is empty")
}
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline)
}
req := rpcRequest{
JSONRPC: jsonRPCVersion,
ID: c.seq.Add(1),
Method: method,
Params: params,
}
if err := json.NewEncoder(conn).Encode(&req); err != nil {
return nil, fmt.Errorf("write backend rpc request %s: %w", method, err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read backend rpc response %s: %w", method, err)
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err)
}
if resp.Error != nil {
return nil, resp.Error
}
if len(resp.Result) == 0 {
return nil, fmt.Errorf("backend rpc %s response missing result", method)
}
var envelope Envelope
if err := json.Unmarshal(resp.Result, &envelope); err != nil {
return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err)
}
return &envelope, nil
}
func (c *Client) dial(ctx context.Context) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, "unix", c.SocketPath)
}
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", c.SocketPath)
}
func (c *Client) deadline(ctx context.Context) (time.Time, bool) {
if deadline, ok := ctx.Deadline(); ok {
return deadline, true
}
if c.Timeout > 0 {
return time.Now().Add(c.Timeout), true
}
return time.Time{}, false
}
type boolParam struct {
Force bool `json:"force"`
}
type pageParam struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type tailParam struct {
Tail int `json:"tail"`
}
type taskIDParam struct {
TaskID string `json:"task_id"`
}
type TextUnitQueryParams struct {
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Destination string `json:"destination,omitempty"`
PathPattern string `json:"path_pattern,omitempty"`
ArchiveEntry string `json:"archive_entry,omitempty"`
PathID *int64 `json:"path_id,omitempty"`
ClassID *int `json:"class_id,omitempty"`
FieldPath string `json:"field_path,omitempty"`
Format string `json:"format,omitempty"`
}
type UnityFSTextAssetPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
ReplacementPath string `json:"replacement_path"`
TargetPath string `json:"target_path"`
ExpectedName *string `json:"expected_name,omitempty"`
}
type UnityFSStringFieldPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
FieldPath string `json:"field_path"`
ReplacementText *string `json:"replacement_text,omitempty"`
ReplacementPath string `json:"replacement_path,omitempty"`
TargetPath string `json:"target_path"`
ExpectedValue *string `json:"expected_value,omitempty"`
}
type UnityFSFieldPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
FieldPath string `json:"field_path"`
Replacement json.RawMessage `json:"replacement"`
TargetPath string `json:"target_path"`
ExpectedValue json.RawMessage `json:"expected_value,omitempty"`
}
// ScheduleMutationParams is shared by schedule.add, schedule.update, and
// schedule.remove. Omitted pointer fields preserve the existing schedule on
// update; clear_* fields explicitly remove values.
type ScheduleMutationParams struct {
ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Action string `json:"action,omitempty"`
Args []string `json:"args,omitempty"`
NextRunUnixSeconds *uint64 `json:"next_run_unix_seconds,omitempty"`
DelaySeconds *uint64 `json:"delay_seconds,omitempty"`
EverySeconds *uint64 `json:"every_seconds,omitempty"`
Count *uint64 `json:"count,omitempty"`
ClearArgs bool `json:"clear_args,omitempty"`
ClearEvery bool `json:"clear_every,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// ScheduleListParams filters the Rust-owned schedule store.
type ScheduleListParams struct {
ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// ScheduleRunParams selects a schedule or runs every due enabled schedule.
type ScheduleRunParams struct {
ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Force bool `json:"force,omitempty"`
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"`
}
// 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) {
return c.rawData(ctx, "translation.proofread", nil)
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
ControllerPID *int `json:"controller_pid,omitempty"`
Force *bool `json:"force,omitempty"`
}
// TaskAccepted is returned when an async backend task is queued.
type TaskAccepted struct {
TaskID string `json:"task_id"`
Kind string `json:"kind"`
}
// TaskRecord is the pollable task state.
type TaskRecord struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Stage *string `json:"stage,omitempty"`
Message *string `json:"message,omitempty"`
CreatedAt uint64 `json:"created_at"`
UpdatedAt uint64 `json:"updated_at"`
StartedAt *uint64 `json:"started_at,omitempty"`
FinishedAt *uint64 `json:"finished_at,omitempty"`
Error *APIError `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
type TaskList struct {
Tasks []TaskRecord `json:"tasks"`
}
type TaskCancelResult struct {
TaskID string `json:"task_id"`
CancelRequested bool `json:"cancel_requested"`
Note string `json:"note,omitempty"`
}
type TaskLogs struct {
TaskID string `json:"task_id"`
Lines []string `json:"lines"`
}
type DoctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message"`
}
type DoctorReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
Healthy bool `json:"healthy"`
Checks []DoctorCheck `json:"checks"`
}
type LogsReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
LogPath string `json:"log_path"`
Exists bool `json:"exists"`
Empty bool `json:"empty"`
Bytes int `json:"bytes"`
TotalLines int `json:"total_lines"`
ReturnedLines int `json:"returned_lines"`
Content string `json:"content"`
}
type DaemonStatusReport struct {
Status string `json:"status"`
StatusCode *string `json:"status_code,omitempty"`
Message string `json:"message"`
Running bool `json:"running"`
PID *int `json:"pid,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
RPCAvailable bool `json:"rpc_available"`
CurrentStage *string `json:"current_stage,omitempty"`
CurrentMessage *string `json:"current_message,omitempty"`
}
type ResourceState struct {
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
StatusPhase string `json:"status_phase,omitempty"`
StatusTerminal bool `json:"status_terminal,omitempty"`
StatusRetryable bool `json:"status_retryable,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
VersionState json.RawMessage `json:"version_state,omitempty"`
LastUpdateStatus *string `json:"last_update_status,omitempty"`
LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"`
}
type ResourceManifestEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes *uint64 `json:"bytes,omitempty"`
BLAKE3 string `json:"blake3,omitempty"`
}
type ResourceManifestPage struct {
Available bool `json:"available"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Entries []ResourceManifestEntry `json:"entries,omitempty"`
}
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
var out DaemonStatusReport
_, err := c.Call(ctx, "daemon.status", nil, &out)
return &out, err
}
func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) {
var out LogsReport
_, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out)
return &out, err
}
func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.stop", nil, &out)
return &out, err
}
func (c *Client) DaemonRestart(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.restart", nil, &out)
return &out, err
}
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.reload", nil, &out)
return &out, err
}
func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) {
var out DoctorReport
_, err := c.Call(ctx, "daemon.doctor", nil, &out)
return &out, err
}
func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) {
var out ResourceState
_, err := c.Call(ctx, "resource.state", nil, &out)
return &out, err
}
func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.verify", nil, &out)
return &out, err
}
func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.repair", nil, &out)
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) ScheduleList(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.list", nil)
}
func (c *Client) ScheduleListFiltered(ctx context.Context, params ScheduleListParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.list", params)
}
func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.add", params)
}
func (c *Client) ScheduleUpdate(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.update", params)
}
func (c *Client) ScheduleRemove(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.remove", params)
}
func (c *Client) ScheduleRun(ctx context.Context, params ScheduleRunParams) (json.RawMessage, error) {
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)
}
func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.versions", nil)
}
func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil)
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "parse.status", nil)
}
func (c *Client) ParseTextUnits(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
return c.rawData(ctx, "parse.text_units", query)
}
func (c *Client) ParseErrors(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
return c.rawData(ctx, "parse.errors", query)
}
func (c *Client) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "localized.status", nil)
}
func (c *Client) UnityFSPatchTextAsset(ctx context.Context, params UnityFSTextAssetPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_text_asset", params)
}
func (c *Client) UnityFSPatchStringField(ctx context.Context, params UnityFSStringFieldPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_string_field", params)
}
func (c *Client) UnityFSPatchField(ctx context.Context, params UnityFSFieldPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_field", params)
}
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
var out TaskRecord
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskList(ctx context.Context) (*TaskList, error) {
var out TaskList
_, err := c.Call(ctx, "task.list", nil, &out)
return &out, err
}
func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) {
var out TaskCancelResult
_, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) {
var out TaskLogs
_, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) {
envelope, err := c.Call(ctx, method, params, nil)
if err != nil {
return nil, err
}
return envelope.Data, nil
}