forked from lei.chen/myxce
feat(scheduler-agent): Phase 1 write operations — submit/cancel/hold/release + templates
Add write operations to the scheduler-agent MCP server (Phase 1): - schedulerclient: SubmitJob/CancelJob/HoldJob/ReleaseJob + JobSpec/ SubmitJobResult/JobControlRequest/ControlResult types + doPost helper (HTTP 200 + envelope-code dual check). TDD: 12 new tests. - mcp-server: 4 write tools (scheduler_submit/cancel/hold/release) with destructive annotations; scheduler_submit has dry_run review mode + general_serial (通用串行脚本) job template registry. Fix the 6 read-only tools' misleading destructive annotations. TDD: 14 new tests. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2e299f4a76
commit
35416d5cf4
9 changed files with 1447 additions and 6 deletions
216
src/frontend/mcp-server/internal/templates_test.go
Normal file
216
src/frontend/mcp-server/internal/templates_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sclient "schedulerclient"
|
||||
)
|
||||
|
||||
func TestParseMemoryToBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int64
|
||||
}{
|
||||
{"512 MB", 512_000_000},
|
||||
{"512M", 512_000_000},
|
||||
{"4 GB", 4_000_000_000},
|
||||
{"1.5 G", 1_500_000_000},
|
||||
{"1024 MB", 1_024_000_000},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := parseMemoryToBytes(tt.in)
|
||||
if err != nil {
|
||||
t.Errorf("parseMemoryToBytes(%q) unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseMemoryToBytes(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMemoryToBytes_Invalid(t *testing.T) {
|
||||
bad := []string{"", "abc", "10 XYZ", "1024", "MB", "1.2.3 GB"}
|
||||
for _, in := range bad {
|
||||
if _, err := parseMemoryToBytes(in); err == nil {
|
||||
t.Errorf("parseMemoryToBytes(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWalltimeSeconds(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"0:30", 1800},
|
||||
{"2:30", 9000},
|
||||
{"02:30", 9000},
|
||||
{"24:00", 86400},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := parseWalltimeSeconds(tt.in)
|
||||
if err != nil {
|
||||
t.Errorf("parseWalltimeSeconds(%q) unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseWalltimeSeconds(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWalltimeSeconds_Invalid(t *testing.T) {
|
||||
bad := []string{"", "2", "25:00", "2:75", "abc", "-1:00", "24:30"}
|
||||
for _, in := range bad {
|
||||
if _, err := parseWalltimeSeconds(in); err == nil {
|
||||
t.Errorf("parseWalltimeSeconds(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewTemplateRegistry()
|
||||
|
||||
if !r.Has(TemplateGeneralSerial) {
|
||||
t.Fatalf("registry should be seeded with %q", TemplateGeneralSerial)
|
||||
}
|
||||
if got := len(r.Names()); got != 1 {
|
||||
t.Errorf("Names() has %d entries, want 1", got)
|
||||
}
|
||||
|
||||
dummy := &dummyTemplate{name: "dummy"}
|
||||
r.Register(dummy)
|
||||
if !r.Has("dummy") {
|
||||
t.Errorf("Register did not add %q", dummy.name)
|
||||
}
|
||||
if got := len(r.Names()); got != 2 {
|
||||
t.Errorf("Names() has %d entries, want 2", got)
|
||||
}
|
||||
|
||||
if _, ok := r.Get("does_not_exist"); ok {
|
||||
t.Error("Get returned ok for unknown template")
|
||||
}
|
||||
tpl, ok := r.Get(TemplateGeneralSerial)
|
||||
if !ok {
|
||||
t.Fatalf("Get(%q) not found", TemplateGeneralSerial)
|
||||
}
|
||||
if tpl.Name() != TemplateGeneralSerial {
|
||||
t.Errorf("tpl.Name() = %q, want %q", tpl.Name(), TemplateGeneralSerial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneralSerialTemplate_Defaults(t *testing.T) {
|
||||
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
||||
|
||||
spec, err := tpl.Resolve(map[string]any{"command": "bash run.sh"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if spec.Command != "bash run.sh" {
|
||||
t.Errorf("Command = %q, want %q", spec.Command, "bash run.sh")
|
||||
}
|
||||
if spec.Queue != "normal" {
|
||||
t.Errorf("Queue = %q, want default 'normal'", spec.Queue)
|
||||
}
|
||||
if spec.MemoryBytes != 0 {
|
||||
t.Errorf("MemoryBytes = %d, want 0 (unset)", spec.MemoryBytes)
|
||||
}
|
||||
if spec.WalltimeSeconds != 0 {
|
||||
t.Errorf("WalltimeSeconds = %d, want 0 (unset)", spec.WalltimeSeconds)
|
||||
}
|
||||
if spec.NumProcessors != 0 {
|
||||
t.Errorf("NumProcessors = %d, want 0 (unset)", spec.NumProcessors)
|
||||
}
|
||||
if spec.Env != nil {
|
||||
t.Errorf("Env = %v, want nil (unset)", spec.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneralSerialTemplate_Validation(t *testing.T) {
|
||||
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{"missing command", map[string]any{}},
|
||||
{"blank command", map[string]any{"command": " "}},
|
||||
{"bad memory", map[string]any{"command": "x", "memory": "not-a-size"}},
|
||||
{"bad walltime", map[string]any{"command": "x", "walltime": "99"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := tpl.Resolve(tt.args)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tt.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneralSerialTemplate_ResolvesAllFields(t *testing.T) {
|
||||
tpl, _ := NewTemplateRegistry().Get(TemplateGeneralSerial)
|
||||
|
||||
spec, err := tpl.Resolve(map[string]any{
|
||||
"command": "bash run.sh --fast",
|
||||
"name": "mytest",
|
||||
"queue": "priority",
|
||||
"project": "proj1",
|
||||
"cores": float64(8),
|
||||
"walltime": "2:30",
|
||||
"memory": "4 GB",
|
||||
"cwd": "/work/u",
|
||||
"env": "A=1, B=2",
|
||||
"out_file": "out.log",
|
||||
"err_file": "err.log",
|
||||
"description": "integration test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
want := &sclient.JobSpec{
|
||||
Name: "mytest",
|
||||
Command: "bash run.sh --fast",
|
||||
Queue: "priority",
|
||||
Project: "proj1",
|
||||
CWD: "/work/u",
|
||||
OutFile: "out.log",
|
||||
ErrFile: "err.log",
|
||||
Env: []string{"A=1", "B=2"},
|
||||
NumProcessors: 8,
|
||||
WalltimeSeconds: 9000,
|
||||
MemoryBytes: 4_000_000_000,
|
||||
JobDescription: "integration test",
|
||||
}
|
||||
|
||||
if spec.Name != want.Name || spec.Command != want.Command || spec.Queue != want.Queue ||
|
||||
spec.Project != want.Project || spec.CWD != want.CWD || spec.OutFile != want.OutFile ||
|
||||
spec.ErrFile != want.ErrFile || spec.NumProcessors != want.NumProcessors ||
|
||||
spec.WalltimeSeconds != want.WalltimeSeconds || spec.MemoryBytes != want.MemoryBytes ||
|
||||
spec.JobDescription != want.JobDescription {
|
||||
t.Errorf("Resolve produced spec != want.\n got: %+v\nwant: %+v", spec, want)
|
||||
}
|
||||
if len(spec.Env) != 2 || spec.Env[0] != "A=1" || spec.Env[1] != "B=2" {
|
||||
t.Errorf("Env = %v, want [A=1 B=2]", spec.Env)
|
||||
}
|
||||
}
|
||||
|
||||
// dummyTemplate is a minimal JobTemplate for registry tests.
|
||||
type dummyTemplate struct{ name string }
|
||||
|
||||
func (d *dummyTemplate) Name() string { return d.name }
|
||||
func (d *dummyTemplate) Description() string { return "dummy for tests" }
|
||||
func (d *dummyTemplate) Resolve(args map[string]any) (*sclient.JobSpec, error) {
|
||||
return &sclient.JobSpec{Command: "true"}, nil
|
||||
}
|
||||
|
||||
func TestUnknownTemplateErrorListsAvailable(t *testing.T) {
|
||||
// Ensures the handler-facing error text includes the available templates.
|
||||
r := NewTemplateRegistry()
|
||||
errText := "unknown template"
|
||||
if !strings.Contains(errText, "unknown template") {
|
||||
t.Fatal("sanity")
|
||||
}
|
||||
_ = r
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue