Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ jobs:
- name: Verify generated contracts are fresh
run: |
task generate
git diff --exit-code -- contracts/gen sdk/typescript/src/gen
git diff --exit-code -- contracts/gen sdk/typescript/src/gen cmd/mecatui/internal/timezones/zones.json

- name: Run offline SDK e2e against same-checkout mecated
run: task sdk:e2e
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release-sdk-typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ jobs:
run: |
set -euo pipefail
task generate
git diff --exit-code -- contracts/gen sdk/typescript/src/gen sdk/typescript/pnpm-lock.yaml
test -z "$(git status --porcelain --untracked-files=all -- contracts/gen sdk/typescript/src/gen sdk/typescript/pnpm-lock.yaml)"
git diff --exit-code -- contracts/gen sdk/typescript/src/gen sdk/typescript/pnpm-lock.yaml cmd/mecatui/internal/timezones/zones.json
test -z "$(git status --porcelain --untracked-files=all -- contracts/gen sdk/typescript/src/gen sdk/typescript/pnpm-lock.yaml cmd/mecatui/internal/timezones/zones.json)"

- name: Lint and format-check SDK
run: task sdk:lint
Expand Down
10 changes: 9 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1238,8 +1238,16 @@ tasks:
- cd provider/openai && GOWORK=off go mod tidy
- cd provider/openaichat && GOWORK=off go mod tidy

generate:timezones:
desc: Refresh the client timezone catalog from the active Go toolchain
cmds:
# Explicit maintenance only: compiler upgrades do not require a catalog refresh.
# Record the active Go version/archive checksum as provenance, not a toolchain pin.
# The catalog contains names, not the runtime's DST rules.
- GOWORK=off go run ./internal/timezonegen/cmd/timezonegen --output cmd/mecatui/internal/timezones/zones.json

generate:
desc: Regenerate Go and TypeScript contracts from contracts/proto via buf
desc: Regenerate Go and TypeScript contracts and configuration reference
cmds:
# Regenerates the gRPC contracts — the harness API (contracts/proto/
# mecatl/v1) AND the store-driver protocol (contracts/proto/mecatl/
Expand Down
111 changes: 77 additions & 34 deletions cmd/mecatui/client/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"

mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
Expand Down Expand Up @@ -222,42 +223,84 @@ func TestGetScheduleMapping(t *testing.T) {
}

func TestCreateScheduleBuildsProto(t *testing.T) {
spec := ScheduleSpec{
Name: "daily",
Prompt: "ship it",
Trigger: ScheduleTrigger{Cron: "*/5 * * * *"},
Mode: "plan",
Misfire: "skip",
MaxFires: 7,
Selector: ScheduleSelector{ProviderID: "anthropic"},
}
fake := &fakeScheduleClient{createResp: &mecatlv1.CreateScheduleResponse{Schedule: sampleProtoSchedule("daily")}}
cl := newScheduleFakeClient(fake)
for _, tc := range []struct {
mode string
mutating bool
want mecatlv1.PermissionMode
}{
{"plan", false, mecatlv1.PermissionMode_PERMISSION_MODE_PLAN},
{ModeDefaultString, true, mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT},
} {
t.Run(tc.mode, func(t *testing.T) {
spec := ScheduleSpec{
Name: "daily",
Prompt: "ship it",
Trigger: ScheduleTrigger{Cron: "*/5 * * * *"},
Mode: tc.mode,
Mutating: tc.mutating,
Misfire: "skip",
MaxFires: 7,
Selector: ScheduleSelector{ProviderID: "anthropic"},
}
fake := &fakeScheduleClient{createResp: &mecatlv1.CreateScheduleResponse{Schedule: sampleProtoSchedule("daily")}}
cl := newScheduleFakeClient(fake)

if _, err := cl.CreateSchedule(context.Background(), spec); err != nil {
t.Fatalf("CreateSchedule: %v", err)
}
if fake.lastCreate == nil || fake.lastCreate.GetSpec() == nil {
t.Fatal("create request not sent")
}
got := fake.lastCreate.GetSpec()
if got.GetName() != "daily" || got.GetPrompt() != "ship it" {
t.Fatalf("spec proto mismapped: %+v", got)
}
if got.GetTrigger().GetCron() != "*/5 * * * *" || got.GetTrigger().GetOneShot() != nil {
t.Fatalf("trigger proto mismapped: %+v", got.GetTrigger())
}
if got.GetMode() != mecatlv1.PermissionMode_PERMISSION_MODE_PLAN {
t.Fatalf("mode proto = %v, want PLAN", got.GetMode())
}
if got.GetMisfire() != mecatlv1.MisfirePolicy_MISFIRE_SKIP {
t.Fatalf("misfire proto = %v, want SKIP", got.GetMisfire())
}
if got.GetMaxFires() != 7 {
t.Fatalf("max_fires proto = %d, want 7", got.GetMaxFires())
if _, err := cl.CreateSchedule(context.Background(), spec); err != nil {
t.Fatalf("CreateSchedule: %v", err)
}
if fake.lastCreate == nil || fake.lastCreate.GetSpec() == nil {
t.Fatal("create request not sent")
}
got := fake.lastCreate.GetSpec()
if got.GetName() != "daily" || got.GetPrompt() != "ship it" {
t.Fatalf("spec proto mismapped: %+v", got)
}
if got.GetTrigger().GetCron() != "*/5 * * * *" || got.GetTrigger().GetOneShot() != nil {
t.Fatalf("trigger proto mismapped: %+v", got.GetTrigger())
}
if got.GetMode() != tc.want || got.GetMutating() != tc.mutating {
t.Fatalf("mode/mutating proto = %v/%t, want %v/%t", got.GetMode(), got.GetMutating(), tc.want, tc.mutating)
}
if got.GetMisfire() != mecatlv1.MisfirePolicy_MISFIRE_SKIP {
t.Fatalf("misfire proto = %v, want SKIP", got.GetMisfire())
}
if got.GetMaxFires() != 7 {
t.Fatalf("max_fires proto = %d, want 7", got.GetMaxFires())
}
if got.GetSelector().GetProviderId() != "anthropic" {
t.Fatalf("selector proto = %+v", got.GetSelector())
}
})
}
if got.GetSelector().GetProviderId() != "anthropic" {
t.Fatalf("selector proto = %+v", got.GetSelector())
}

func TestCreateScheduleFormSpecSerialization(t *testing.T) {
instant := time.Date(2027, 1, 3, 17, 0, 0, 0, time.UTC)
for _, tc := range []struct {
trigger ScheduleTrigger
mode string
mutating bool
wireMode mecatlv1.PermissionMode
}{
{ScheduleTrigger{Cron: "0 9 * * 1-5"}, "plan", false, mecatlv1.PermissionMode_PERMISSION_MODE_PLAN},
{ScheduleTrigger{OneShot: instant}, "", true, mecatlv1.PermissionMode_PERMISSION_MODE_UNSPECIFIED},
} {
t.Run(tc.mode, func(t *testing.T) {
fake := &fakeScheduleClient{createResp: &mecatlv1.CreateScheduleResponse{Schedule: sampleProtoSchedule("review")}}
cl := newScheduleFakeClient(fake)
spec := ScheduleSpec{Name: "review", Prompt: "first\nsecond", Trigger: tc.trigger, Timezone: "America/Los_Angeles", Mode: tc.mode, Mutating: tc.mutating, Singleton: true}
if _, err := cl.CreateSchedule(context.Background(), spec); err != nil {
t.Fatal(err)
}
trigger := &mecatlv1.TriggerSpec{Cron: tc.trigger.Cron}
if !tc.trigger.OneShot.IsZero() {
trigger.OneShot = timestamppb.New(instant)
}
want := &mecatlv1.ScheduleSpec{Name: "review", Prompt: "first\nsecond", Trigger: trigger, Timezone: "America/Los_Angeles", Mode: tc.wireMode, Mutating: tc.mutating, Singleton: true, Misfire: mecatlv1.MisfirePolicy_MISFIRE_FIRE_ONCE_NOW}
if !proto.Equal(fake.lastCreate.GetSpec(), want) {
t.Fatalf("form spec gained overrides or lost chosen fields: got %v, want %v", fake.lastCreate.GetSpec(), want)
}
})
}
}

Expand Down
46 changes: 46 additions & 0 deletions cmd/mecatui/internal/timezones/timezones.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Package timezones provides the client's checked-in selectable timezone names and
// links Go's standard embedded rule fallback. Catalog names do not pin runtime rules.
package timezones

import (
_ "embed"
"encoding/json"
"fmt"
"slices"
"time"
_ "time/tzdata" // Standard fallback when the host has no zoneinfo.
)

//go:embed zones.json
var catalogJSON []byte

var names = func() []string {
var catalog struct {
Names []string `json:"names"`
}
if err := json.Unmarshal(catalogJSON, &catalog); err != nil {
panic("invalid bundled timezone catalog; run task generate:timezones: " + err.Error())
}
return catalog.Names
}()

// Names returns an independent sorted list: UTC and loadable slash-qualified archive IDs.
func Names() []string { return slices.Clone(names) }

// Valid reports exact catalog membership; abbreviations, Local and misspellings are rejected.
func Valid(name string) bool {
_, ok := slices.BinarySearch(names, name)
return ok
}

// Location validates the exact identifier and loads its runtime rules without a silent fallback.
func Location(name string) (*time.Location, error) {
if !Valid(name) {
return nil, fmt.Errorf("choose UTC or a listed IANA timezone (for example America/Los_Angeles)")
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("cannot load timezone %q: %w", name, err)
}
return loc, nil
}
120 changes: 120 additions & 0 deletions cmd/mecatui/internal/timezones/timezones_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package timezones

import (
"archive/zip"
"bytes"
"encoding/hex"
"encoding/json"
"go/version"
"io"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"

"github.com/stacklok/mecatl/internal/timezonegen"
)

func TestCatalogIntegrity(t *testing.T) {
const regen = "task generate:timezones"
var catalog timezonegen.Catalog
if err := json.Unmarshal(catalogJSON, &catalog); err != nil {
t.Fatalf("invalid catalog: %v; run: %s", err, regen)
}
if !version.IsValid(catalog.GoVersion) {
t.Fatalf("invalid source version %q; run: %s", catalog.GoVersion, regen)
}
hash, err := hex.DecodeString(catalog.ArchiveSHA256)
if err != nil || len(hash) != 32 {
t.Fatalf("invalid archive fingerprint: %q; run: %s", catalog.ArchiveSHA256, regen)
}
if !slices.IsSorted(catalog.Names) || len(catalog.Names) == 0 || !slices.Contains(catalog.Names, "UTC") {
t.Fatalf("catalog must be sorted, nonempty, and include UTC; run: %s", regen)
}
for i, name := range catalog.Names {
if i > 0 && name == catalog.Names[i-1] {
t.Fatalf("duplicate %s; run: %s", name, regen)
}
if name != "UTC" && (!strings.Contains(name, "/") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "..")) {
t.Fatalf("invalid identifier shape %q; run: %s", name, regen)
}
if _, err := Location(name); err != nil {
t.Fatalf("catalog name %s not loadable: %v; run: %s", name, err, regen)
}
}
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(append(data, '\n'), catalogJSON) {
t.Errorf("catalog serialization drift; run: %s", regen)
}
}

func TestNamesAndValidation(t *testing.T) {
for _, name := range []string{"UTC", "America/Los_Angeles", "US/Pacific", "Etc/GMT+5", "Asia/Kolkata"} {
if !Valid(name) {
t.Errorf("missing valid ID %q", name)
}
}
for _, name := range []string{"", "Local", "PST", "EST", "GMT", "utc", "America/Typo", "America/Los Angeles", " UTC", "../UTC"} {
if Valid(name) {
t.Errorf("accepted %q", name)
}
if _, err := Location(name); err == nil {
t.Errorf("loaded invalid %q", name)
}
}
names := Names()
names[0] = "corrupted"
if Names()[0] == "corrupted" {
t.Fatal("Names leaked mutable catalog")
}
}

func TestArchiveRulesAndRuntimeDST(t *testing.T) {
//nolint:staticcheck // SA1019: the rule fixture belongs to the test's running toolchain, not an ambient go on PATH.
archive, err := zip.OpenReader(filepath.Join(runtime.GOROOT(), "lib", "time", "zoneinfo.zip"))
if err != nil {
t.Fatal(err)
}
defer archive.Close()
var rules []byte
for _, entry := range archive.File {
if entry.Name != "America/New_York" {
continue
}
r, err := entry.Open()
if err != nil {
t.Fatal(err)
}
rules, err = io.ReadAll(r)
closeErr := r.Close()
if err != nil || closeErr != nil {
t.Fatalf("read=%v close=%v", err, closeErr)
}
break
}
fixture, err := time.LoadLocationFromTZData("America/New_York", rules)
if err != nil {
t.Fatal(err)
}
loaded, err := Location("America/New_York")
if err != nil {
t.Fatal(err)
}
for _, loc := range []*time.Location{fixture, loaded} {
for _, tc := range []struct {
month time.Month
offset int
}{{time.January, -5 * 3600}, {time.July, -4 * 3600}} {
at := time.Date(2026, tc.month, 15, 9, 0, 0, 0, loc)
_, offset := at.Zone()
if offset != tc.offset || at.UTC().Hour() != 9-tc.offset/3600 {
t.Fatalf("wrong local 9am: %v offset=%d", at, offset)
}
}
}
}
Loading
Loading