diff --git a/go.mod b/go.mod index 2236542..68edb0a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.26.0 require ( - github.com/flashcatcloud/go-flashduty v0.15.12 + github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d github.com/mattn/go-runewidth v0.0.30 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index e4fc4de..ba75cc8 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.15.12 h1:+x+wSCcVKpxlBfGBMU5vftwk6ducoumYKmlmyDhEO8c= -github.com/flashcatcloud/go-flashduty v0.15.12/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= +github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d h1:LU8KL6c3fSBVtevbAyLkzNcEltvtWH3wuqeQcNvKMjI= +github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= diff --git a/internal/cli/automation.go b/internal/cli/automation.go index e31d3fb..beba97a 100644 --- a/internal/cli/automation.go +++ b/internal/cli/automation.go @@ -49,6 +49,7 @@ func newAutomationCreateCmd() *cobra.Command { promptFile string environmentKind string environmentID string + timezone string ) cmd := &cobra.Command{ @@ -61,10 +62,11 @@ asks to create it disabled. team_id=0 means personal scope; --team-id >0 creates the rule under that team. The scope is immutable after creation. Schedule helpers build a 5-field cron expression evaluated in the rule's - timezone, which defaults to the caller's member timezone, then the account - timezone. Pass the user's local wall-clock time to --at or --cron-expr; - do not convert it to UTC first. This command has no --timezone flag; use - safari automation-rule-create --timezone to pin a different one. + timezone. Pass --timezone when the user names one. When --timezone is + omitted, leave it unset so the server uses the caller's member timezone, + then the account timezone, then Asia/Shanghai. Do not send a timezone + default from the CLI. Pass the user's local wall-clock time to --at or + --cron-expr; do not convert it to UTC first. For HTTP POST-only rules, pass --http-post-trigger without a schedule; the CLI sends a valid placeholder cron and disables the schedule trigger.`, "Automations", "RuleWriteCreate"), @@ -102,6 +104,9 @@ the rule under that team. The scope is immutable after creation. EnvironmentKind: strings.TrimSpace(environmentKind), EnvironmentID: strings.TrimSpace(environmentID), } + if cmd.Flags().Changed("timezone") { + req.Timezone = strings.TrimSpace(timezone) + } out, _, err := ctx.Client.Automations.RuleWriteCreate(cmdContext(ctx.Cmd), req) if err != nil { return err @@ -117,6 +122,7 @@ the rule under that team. The scope is immutable after creation. cmd.Flags().StringVar(&at, "at", "", "Local time in HH:MM (rule's timezone); for hourly schedules, only the minute is used. "+automationTimezoneNote) cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7") cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression in the rule's timezone; overrides --schedule helpers. "+automationTimezoneNote) + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name cron_expr is evaluated in, for example Asia/Shanghai. The server must be able to load it; an invalid value is rejected. Omit to use the caller's member timezone, then the account timezone, then Asia/Shanghai.") cmd.Flags().BoolVar(&disabled, "disabled", false, "Create the Automation disabled") cmd.Flags().BoolVar(&scheduleEnabled, "schedule-enabled", true, "Whether the schedule trigger is enabled") cmd.Flags().BoolVar(&httpPostTrigger, "http-post-trigger", false, "Create and enable an HTTP POST trigger") @@ -213,6 +219,7 @@ func newAutomationUpdateCmd() *cobra.Command { enableHTTPPostTrigger bool disableHTTPPostTrigger bool rotateHTTPPostToken bool + timezone string ) cmd := &cobra.Command{ @@ -224,10 +231,13 @@ func newAutomationUpdateCmd() *cobra.Command { after creation; create a new Automation if the target person/team scope needs to change. Schedule helpers build a 5-field cron expression evaluated in the rule's - timezone, set at creation from the caller's member timezone, then the account - timezone. Pass the user's local wall-clock time to --at or --cron-expr; - do not convert it to UTC first. This command has no --timezone flag; the - rule's timezone cannot be changed after creation.`, "Automations", "RuleWriteUpdate"), + timezone. Pass --timezone when the user names one. When --timezone is + omitted, the stored timezone stays, including a legacy empty string. Pass + the user's local wall-clock time to --at or --cron-expr; do not convert it + to UTC first. An explicit empty --timezone "" is sent and stored as UTC, + not the account default. Update recalculates the next fire immediately; + read schedule_next_fire_at_ms from the response and do not assume the + previously scheduled occurrence will still run.`, "Automations", "RuleWriteUpdate"), Example: ` flashduty automation update auto_123 --name "Daily brief v2" --cron-expr "15 1 * * *" flashduty automation update auto_123 --disable flashduty automation update auto_123 --enable-http-post-trigger --rotate-http-post-token`, @@ -277,6 +287,10 @@ func newAutomationUpdateCmd() *cobra.Command { req.CronExpr = flashduty.String(cron) changed = true } + if cmd.Flags().Changed("timezone") { + req.Timezone = flashduty.String(strings.TrimSpace(timezone)) + changed = true + } if enableSchedule { req.ScheduleTriggerEnabled = flashduty.Bool(true) changed = true @@ -323,6 +337,7 @@ func newAutomationUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&at, "at", "", "Local time in HH:MM (rule's timezone); for hourly schedules, only the minute is used. "+automationTimezoneNote) cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7") cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression in the rule's timezone; overrides --schedule helpers. "+automationTimezoneNote) + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name cron_expr is evaluated in. The server must be able to load it; an invalid value is rejected. Omit to leave the stored timezone unchanged, including a legacy empty string. An explicit empty string is stored as UTC, not the account default.") cmd.Flags().BoolVar(&enableRule, "enable", false, "Enable the Automation") cmd.Flags().BoolVar(&disableRule, "disable", false, "Disable the Automation") cmd.Flags().BoolVar(&enableSchedule, "enable-schedule", false, "Enable the schedule trigger") diff --git a/internal/cli/automation_test.go b/internal/cli/automation_test.go index 15f6d89..5d55449 100644 --- a/internal/cli/automation_test.go +++ b/internal/cli/automation_test.go @@ -47,13 +47,172 @@ func TestAutomationScheduleHelpDocumentsTimezone(t *testing.T) { } for _, want := range []string{ "do not convert it to UTC", + "--timezone", automationTimezoneNote, } { if !strings.Contains(out, want) { t.Fatalf("%v help missing %q\n%s", args, want, out) } } + if strings.Contains(out, "has no --timezone flag") { + t.Fatalf("%v help still says the command has no --timezone flag\n%s", args, out) + } + } +} + +func TestAutomationCreateTimezone(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "create", + "--name", "Daily SRE brief", + "--schedule", "daily", + "--at", "09:00", + "--prompt", "Summarize yesterday's incidents", + "--timezone", "Asia/Shanghai", + "--json", + ) + if err != nil { + t.Fatalf("[automation-create-timezone] unexpected error: %v", err) + } + assertBody(t, stub.lastBody, "timezone", "Asia/Shanghai") +} + +func TestAutomationCreateExplicitEmptyTimezoneIsOmitted(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "create", + "--name", "Daily SRE brief", + "--prompt", "Summarize yesterday's incidents", + "--timezone", "", + "--json", + ) + if err != nil { + t.Fatalf("[automation-create-empty-timezone] unexpected error: %v", err) + } + // CreateRequest.Timezone is a string with omitempty, so an explicit "" + // is dropped. Update's pointer is what sends an empty string. + if _, ok := stub.lastBody["timezone"]; ok { + t.Fatalf("[automation-create-empty-timezone] empty string must not be on the wire, body=%#v", stub.lastBody) + } +} + +func TestAutomationCreateOmitsTimezone(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "create", + "--name", "Daily SRE brief", + "--prompt", "Summarize yesterday's incidents", + "--json", + ) + if err != nil { + t.Fatalf("[automation-create-omit-timezone] unexpected error: %v", err) + } + if _, ok := stub.lastBody["timezone"]; ok { + t.Fatalf("[automation-create-omit-timezone] timezone must be omitted, body=%#v", stub.lastBody) + } +} + +func TestAutomationUpdateTimezone(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "update", "auto_123", + "--timezone", "Asia/Tokyo", + "--json", + ) + if err != nil { + t.Fatalf("[automation-update-timezone] unexpected error: %v", err) + } + assertBody(t, stub.lastBody, "rule_id", "auto_123") + assertBody(t, stub.lastBody, "timezone", "Asia/Tokyo") + if _, ok := stub.lastBody["cron_expr"]; ok { + t.Fatalf("[automation-update-timezone] cron_expr must stay omitted, body=%#v", stub.lastBody) + } +} + +func TestAutomationUpdateEmptyTimezoneIsSent(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "update", "auto_123", + "--timezone", "", + "--json", + ) + if err != nil { + t.Fatalf("[automation-update-empty-timezone] unexpected error: %v", err) + } + assertBody(t, stub.lastBody, "timezone", "") +} + +func TestAutomationUpdateOmitsTimezone(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + _, err := execCommand( + "automation", "update", "auto_123", + "--name", "Daily brief v2", + "--json", + ) + if err != nil { + t.Fatalf("[automation-update-omit-timezone] unexpected error: %v", err) + } + assertBody(t, stub.lastBody, "name", "Daily brief v2") + if _, ok := stub.lastBody["timezone"]; ok { + t.Fatalf("[automation-update-omit-timezone] timezone must stay nil, body=%#v", stub.lastBody) + } +} + +func TestSafariAutomationRuleUpdateTimezone(t *testing.T) { + saveAndResetGlobals(t) + + help, err := execCommand("safari", "automation-rule-update", "--help") + if err != nil { + t.Fatalf("[safari-update-timezone] help: %v", err) + } + if !strings.Contains(help, "--timezone") { + t.Fatalf("[safari-update-timezone] help missing --timezone\n%s", help) + } + + stub := newGFStub(t) + _, err = execCommand( + "safari", "automation-rule-update", "arule_1", + "--name", "kept", + "--json", + ) + if err != nil { + t.Fatalf("[safari-update-timezone] omit: %v", err) + } + if _, ok := stub.lastBody["timezone"]; ok { + t.Fatalf("[safari-update-timezone] omitted flag must not send timezone, body=%#v", stub.lastBody) + } + + _, err = execCommand( + "safari", "automation-rule-update", "arule_1", + "--timezone", "", + "--json", + ) + if err != nil { + t.Fatalf("[safari-update-timezone] empty: %v", err) + } + assertBody(t, stub.lastBody, "timezone", "") + + _, err = execCommand( + "safari", "automation-rule-update", "arule_1", + "--timezone", "Europe/London", + "--json", + ) + if err != nil { + t.Fatalf("[safari-update-timezone] named: %v", err) } + assertBody(t, stub.lastBody, "timezone", "Europe/London") } func TestAutomationCreateHTTPPostOnly(t *testing.T) { diff --git a/internal/cli/zz_generated_automations.go b/internal/cli/zz_generated_automations.go index f8b9de1..052703f 100644 --- a/internal/cli/zz_generated_automations.go +++ b/internal/cli/zz_generated_automations.go @@ -462,6 +462,7 @@ func genAutomationsRuleWriteUpdateCmd() *cobra.Command { var fTeamID int64 var fEnabled bool var fCronExpr string + var fTimezone string var fScheduleTriggerEnabled bool var fPrompt string var fEnvironmentKind string @@ -486,6 +487,7 @@ Request fields: --team-id int — Reassign the rule's scope: 0 converts to a personal rule (only the rule owner may convert a team rule); >0 moves it into a team the caller belongs to. Omit to leave unchanged. (min 0) --enabled bool — Whether the rule is enabled. --cron-expr string — Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported. + --timezone string — New IANA timezone for evaluating 'cron_expr'. Omit or send null to leave the current timezone unchanged. --schedule-trigger-enabled bool — Whether the schedule trigger is enabled. --prompt string — New task prompt. --environment-kind string — Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc] @@ -547,6 +549,9 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le if cmd.Flags().Changed("cron-expr") { body["cron_expr"] = fCronExpr } + if cmd.Flags().Changed("timezone") { + body["timezone"] = fTimezone + } if cmd.Flags().Changed("schedule-trigger-enabled") { body["schedule_trigger_enabled"] = fScheduleTriggerEnabled } @@ -596,6 +601,7 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le cmd.Flags().Int64Var(&fTeamID, "team-id", 0, "Reassign the rule's scope: 0 converts to a personal rule (only the rule owner may convert a team rule); >0 moves it into a team the caller belongs to. Omit to leave unchanged. (min 0)") cmd.Flags().BoolVar(&fEnabled, "enabled", false, "Whether the rule is enabled.") cmd.Flags().StringVar(&fCronExpr, "cron-expr", "", "Run cadence. Supports 4 fields ('hour day month weekday', minute defaults to 0) and 5 fields ('minute hour day month weekday'). The minute must be one fixed integer; 6-field seconds are not supported.") + cmd.Flags().StringVar(&fTimezone, "timezone", "", "New IANA timezone for evaluating 'cron_expr'. Omit or send null to leave the current timezone unchanged.") cmd.Flags().BoolVar(&fScheduleTriggerEnabled, "schedule-trigger-enabled", false, "Whether the schedule trigger is enabled.") cmd.Flags().StringVar(&fPrompt, "prompt", "", "New task prompt.") cmd.Flags().StringVar(&fEnvironmentKind, "environment-kind", "", "Runtime environment kind. Omit or send an empty value for automatic selection. [cloud, byoc]") diff --git a/skills/flashduty/reference/automation.md b/skills/flashduty/reference/automation.md index f6f6003..d33ea60 100644 --- a/skills/flashduty/reference/automation.md +++ b/skills/flashduty/reference/automation.md @@ -30,14 +30,17 @@ Prereq: `SKILL.md` read. Automations create AI SRE sessions on a schedule or thr ## Scheduling - Default create behavior: enabled immediately. Use `--disabled` only if the user asks for a disabled Automation. -- `create`/`update` expose no `--timezone` flag. The cron expression runs in the rule's timezone, which the server resolves from the caller's member timezone, then the account timezone (a server-side default applies when neither is set). -- Pass the user's local wall-clock time directly to `--at` or `--cron-expr` — do not convert it to UTC first. The rule already runs in the caller's own resolved timezone, so a manual UTC conversion shifts the schedule by the account's UTC offset. -- Helper schedules (times are in the rule's resolved timezone, not UTC): +- If the user names a timezone, pass `--timezone` with that IANA name on `create` or `update`. The cron expression is that timezone's wall clock. Do not convert it to UTC before calling. +- `create` without `--timezone` still uses the caller's member timezone, then the account timezone, then Asia/Shanghai. Do not invent a timezone in the CLI. +- `update` without `--timezone` keeps the stored value, including a legacy empty string. +- `update` recalculates the next fire immediately. Read `schedule_next_fire_at_ms` from the response and tell the user. Do not assume the previously scheduled occurrence will still run. +- An explicit empty string (`--timezone ""`) is UTC, not the account default. On `update` that empty string is sent and stored. On `create`, omit the flag for the default chain above; do not pass an empty string to mean UTC. +- Pass the user's local wall-clock time directly to `--at` or `--cron-expr`. +- Helper schedules (times are in the rule's timezone, not UTC): - `--schedule hourly --at 00:15` -> minute 15 of every hour. - `--schedule daily --at 01:30` -> every day at 01:30. - `--schedule weekly --weekday mon --at 02:00` -> every Monday at 02:00. - For exact minute-level control, use `--cron-expr ' '` in that same local time. -- To pin a rule to a specific timezone (e.g. UTC) regardless of the caller's default, use `safari automation-rule-create --timezone ` instead — the curated `create`/`update` commands cannot set it, and `update` cannot change it after creation. - HTTP POST-only rule: pass `--http-post-trigger` without schedule flags. The CLI sends a placeholder cron and disables the schedule trigger. ## Hot flow - create from chat @@ -119,6 +122,7 @@ Create an Automation - `--schedule` string - `--schedule-enabled` bool - `--team-id` int64 +- `--timezone` string - `--weekday` string - response: single object (`data` unwrapped to the top level) — fields: account_id (integer); can_edit (boolean); created_at (string); cron_expr (string); enabled (boolean); environment_id (string); environment_kind (string); http_post_token (string); http_post_trigger_enabled (boolean); http_post_trigger_id (string); http_post_trigger_url (string); name (string); oncall_incident_channel_ids (array); oncall_incident_severities (array); oncall_incident_trigger_enabled (boolean); oncall_incident_trigger_id (string); owner_id (integer); prompt (string); rule_id (string); run_scope (string); schedule_next_fire_at_ms (string); schedule_trigger_enabled (boolean); schedule_trigger_id (string); team_id (integer); timezone (string); updated_at (string) @@ -177,6 +181,7 @@ Update an Automation - `--prompt-file` string - `--rotate-http-post-token` bool - `--schedule` string +- `--timezone` string - `--weekday` string - response: same shape as `create` above @@ -185,6 +190,7 @@ Update an Automation ## Gotchas - **Do not ask form-like follow-up questions** when the request is clear enough. Choose practical defaults: personal scope when no team is named, enabled on create, daily 09:00 for a vague daily schedule, Monday 09:00 for a vague weekly schedule. +- **An explicit empty `--timezone ""` is UTC, not the account default.** On `update` that value is sent and stored. Omitting the flag on `update` keeps the stored timezone, including a legacy empty string. Omitting it on `create` uses member, then account, then Asia/Shanghai. After `update`, read `schedule_next_fire_at_ms` and tell the user; the previous occurrence is not guaranteed to run. - **Ask only when required data is missing**: task prompt, trigger token for `fire`, or an ambiguous target rule for update/delete. - **`update` cannot move personal/team scope.** If the user asks to move scope, create a replacement Automation in the new scope and then delete or disable the old one after confirmation. - **Use `--prompt-file` for long prompts.** Shell quoting is the most common failure when the prompt contains quotes, markdown, or JSON. diff --git a/skills/flashduty/reference/safari.md b/skills/flashduty/reference/safari.md index 9c29279..48c7f24 100644 --- a/skills/flashduty/reference/safari.md +++ b/skills/flashduty/reference/safari.md @@ -227,6 +227,7 @@ Update Automation rule - `` (positional, required) string — Target rule ID, from the list returned by 'POST /safari/automation/rule/list'. - `--schedule-trigger-enabled` bool — Whether the schedule trigger is enabled. - `--team-id` int64 — Reassign the rule's scope: 0 converts to a personal rule (only the rule owner may convert a team rule); >0 moves it into a team the caller belongs to. Omit to leave unchanged. (min 0) +- `--timezone` string — New IANA timezone for evaluating 'cron_expr'. Omit or send null to leave the current timezone unchanged. - response: same shape as `automation-rule-create` above ### automation-run-list