alt/apps/cli/internal/operator/runner.go
toki 6ad82e2417 feat(cli): add operator market data workflow support
- Add client, output, parser_map, runner for headless operator workflow
- Support market data status query scenario
- Archive completed G07 subtask documents (02+01_api_market)
- Update CLI and scenario implementations
2026-06-01 10:45:09 +09:00

282 lines
8 KiB
Go

package operator
import (
"context"
"fmt"
"io"
"time"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
)
// Run command exit codes. They form the terminal contract scripts rely on to
// tell a healthy run, a typed/expectation failure, bad input, and a dead
// transport apart.
const (
codeOK = 0 // every step matched its expectation
codeAppError = 1 // unexpected typed ErrorInfo or expectation mismatch
codeBadInput = 2 // unsupported action reached the runner
codeTransport = 3 // connect/disconnect/timeout before a typed response
)
// Step status values written to the output stream.
const (
statusOK = "ok"
statusError = "error"
statusMismatch = "mismatch"
statusTransportError = "transport_error"
)
// Run summary status values.
const (
summaryOK = "ok"
summaryFailed = "failed"
summaryTransport = "transport_error"
)
// Identity the CLI presents to the API in a hello handshake.
const (
clientName = "alt-operator-cli"
clientVersion = "dev"
altProtocolVersion = "alt.v1"
)
// marketByName maps scenario market strings onto the contract enum. The keys
// match scenario.go's validMarkets so a value accepted at validation always has
// a mapping here.
var marketByName = map[string]altv1.Market{
"": altv1.Market_MARKET_UNSPECIFIED,
"unspecified": altv1.Market_MARKET_UNSPECIFIED,
"kr": altv1.Market_MARKET_KR,
"us": altv1.Market_MARKET_US,
}
// timeframeByName maps scenario timeframe strings onto the contract enum.
var timeframeByName = map[string]altv1.Timeframe{
"daily": altv1.Timeframe_TIMEFRAME_DAILY,
"minute_1": altv1.Timeframe_TIMEFRAME_MINUTE_1,
"minute_5": altv1.Timeframe_TIMEFRAME_MINUTE_5,
}
// RunOptions configures a scenario run.
type RunOptions struct {
// APIURL is the ws/wss endpoint of the API control plane.
APIURL string
// Timeout is the per-step request timeout. When zero, the scenario timeout
// is used, falling back to 5s.
Timeout time.Duration
// Format selects the output rendering.
Format OutputFormat
}
// RunScenario connects to the API, executes each step, writes machine-readable
// events to stdout, and returns the process exit code. A transport failure
// before any typed response yields codeTransport; an unexpected typed ErrorInfo
// or a failed expectation yields codeAppError.
func RunScenario(ctx context.Context, sc *Scenario, opts RunOptions, stdout io.Writer) int {
out := NewWriter(stdout, opts.Format)
timeout := opts.Timeout
if timeout <= 0 {
timeout = time.Duration(sc.Timeout)
}
if timeout <= 0 {
timeout = 5 * time.Second
}
dialCtx, cancelDial := context.WithTimeout(ctx, timeout)
client, err := Dial(dialCtx, opts.APIURL)
cancelDial()
if err != nil {
out.WriteStep(StepEvent{
Scenario: sc.Name,
Step: firstStepID(sc),
Action: firstStepAction(sc),
Status: statusTransportError,
Count: noCount,
ErrorMessage: err.Error(),
})
out.WriteSummary(RunSummary{Scenario: sc.Name, Status: summaryTransport, Steps: len(sc.Steps), Passed: 0})
return codeTransport
}
defer client.Close()
passed := 0
exitCode := codeOK
for _, step := range sc.Steps {
ev, code := runStep(ctx, client, sc.Name, step, timeout)
out.WriteStep(ev)
if code == codeOK {
passed++
} else if code > exitCode {
exitCode = code
}
if code == codeTransport {
// The socket dropped mid-run; remaining steps cannot be trusted.
break
}
}
out.WriteSummary(RunSummary{
Scenario: sc.Name,
Status: summaryStatus(exitCode),
Steps: len(sc.Steps),
Passed: passed,
})
return exitCode
}
func summaryStatus(exitCode int) string {
switch exitCode {
case codeOK:
return summaryOK
case codeTransport:
return summaryTransport
default:
return summaryFailed
}
}
func firstStepID(sc *Scenario) string {
if len(sc.Steps) > 0 {
return sc.Steps[0].ID
}
return ""
}
func firstStepAction(sc *Scenario) string {
if len(sc.Steps) > 0 {
return string(sc.Steps[0].Action)
}
return ""
}
// runStep executes one step and classifies the result into a StepEvent and an
// exit code.
func runStep(ctx context.Context, client *APIClient, scenario string, step Step, timeout time.Duration) (StepEvent, int) {
stepCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ev := StepEvent{Scenario: scenario, Step: step.ID, Action: string(step.Action), Count: noCount}
switch step.Action {
case ActionHello:
resp, err := client.Hello(stepCtx, &altv1.HelloRequest{
ClientName: clientName,
ClientVersion: clientVersion,
AltProtocolVersion: altProtocolVersion,
})
if err != nil {
return transportEvent(ev, err)
}
return evaluateHello(ev, step, resp)
case ActionListInstruments:
resp, err := client.ListInstruments(stepCtx, &altv1.ListInstrumentsRequest{
Market: marketByName[step.Request.Market],
})
if err != nil {
return transportEvent(ev, err)
}
return evaluateMarket(ev, step, resp.GetError(), len(resp.GetInstruments()))
case ActionListBars:
resp, err := client.ListBars(stepCtx, &altv1.ListBarsRequest{
InstrumentId: step.Request.InstrumentID,
Timeframe: timeframeByName[step.Request.Timeframe],
FromUnixMs: step.Request.FromUnixMs,
ToUnixMs: step.Request.ToUnixMs,
})
if err != nil {
return transportEvent(ev, err)
}
return evaluateMarket(ev, step, resp.GetError(), len(resp.GetBars()))
default:
// Validation rejects unknown actions, so reaching here means a runner is
// missing for a registered action.
ev.Status = statusError
ev.ErrorCode = "unsupported_action"
ev.ErrorMessage = fmt.Sprintf("no runner for action %q", step.Action)
return ev, codeBadInput
}
}
func transportEvent(ev StepEvent, err error) (StepEvent, int) {
ev.Status = statusTransportError
ev.Count = noCount
ev.ErrorMessage = err.Error()
return ev, codeTransport
}
// evaluateHello checks a hello response against the step expectation. Hello has
// no typed ErrorInfo, so an expected "error" status is itself a mismatch.
func evaluateHello(ev StepEvent, step Step, resp *altv1.HelloResponse) (StepEvent, int) {
if step.Expect.Status == statusError {
ev.Status = statusMismatch
ev.ErrorMessage = "hello cannot return a typed error but the step expected one"
return ev, codeAppError
}
if missing := missingCapabilities(step.Expect.Capabilities, resp.GetCapabilities()); missing != "" {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("missing expected capability %q", missing)
return ev, codeAppError
}
ev.Status = statusOK
return ev, codeOK
}
// evaluateMarket checks a market response. errInfo is the typed ErrorInfo (nil
// when the call succeeded) and count is the number of returned results.
func evaluateMarket(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, count int) (StepEvent, int) {
expectsError := step.Expect.Status == statusError
if errInfo != nil {
ev.ErrorCode = errInfo.GetCode()
ev.ErrorMessage = errInfo.GetMessage()
if !expectsError {
ev.Status = statusError
return ev, codeAppError
}
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
ev.Status = statusMismatch
return ev, codeAppError
}
// Expected typed error arrived: the step passed.
ev.Status = statusOK
return ev, codeOK
}
// Successful response with data.
ev.Count = count
if expectsError {
ev.Status = statusMismatch
ev.ErrorMessage = "expected a typed error but the request succeeded"
return ev, codeAppError
}
if step.Expect.MinCount != nil && count < *step.Expect.MinCount {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected at least %d results, got %d", *step.Expect.MinCount, count)
return ev, codeAppError
}
ev.Status = statusOK
return ev, codeOK
}
// missingCapabilities returns the first wanted capability absent from have, or
// "" when all are present.
func missingCapabilities(want, have []string) string {
if len(want) == 0 {
return ""
}
set := make(map[string]bool, len(have))
for _, c := range have {
set[c] = true
}
for _, c := range want {
if !set[c] {
return c
}
}
return ""
}