diff --git a/.github/workflows/cli-e2e-tests.yml b/.github/workflows/cli-e2e-tests.yml index e77a30b1ff..4def5beb9a 100644 --- a/.github/workflows/cli-e2e-tests.yml +++ b/.github/workflows/cli-e2e-tests.yml @@ -3,24 +3,33 @@ name: cli-e2e-tests on: pull_request: paths: + - ".github/workflows/cli-e2e-tests.yml" - "cmd/hatchet-cli/cli/templates/**" - "cmd/hatchet-cli/cli/quickstart.go" - "cmd/hatchet-cli/cli/internal/templater/**" - "cmd/hatchet-cli/cli/quickstart_e2e_test.go" - - ".github/workflows/test-templates.yml" + - "cmd/hatchet-cli/cli/*.go" + - "cmd/hatchet-cli/cli/*_e2e_test.go" + - "cmd/hatchet-cli/cli/tui/**" + - "cmd/hatchet-cli/cli/testharness/**" push: branches: - main paths: + - ".github/workflows/cli-e2e-tests.yml" - "cmd/hatchet-cli/cli/templates/**" - "cmd/hatchet-cli/cli/quickstart.go" - "cmd/hatchet-cli/cli/internal/templater/**" - "cmd/hatchet-cli/cli/quickstart_e2e_test.go" + - "cmd/hatchet-cli/cli/*.go" + - "cmd/hatchet-cli/cli/*_e2e_test.go" + - "cmd/hatchet-cli/cli/tui/**" + - "cmd/hatchet-cli/cli/testharness/**" jobs: test-templates: runs-on: ubicloud-standard-4 - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Checkout code @@ -34,7 +43,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.14" + python-version: "3.13" # pinned because of poetry dependency issue - name: Set up Node.js uses: actions/setup-node@v6 @@ -56,6 +65,9 @@ jobs: - name: Install Bun uses: oven-sh/setup-bun@v2 + - name: Install tmux + run: sudo apt-get install -y tmux + - name: Build CLI run: | cd cmd/hatchet-cli @@ -79,9 +91,17 @@ jobs: cd cmd/hatchet-cli/cli go test -tags e2e_cli -run TestQuickstartTemplates -v -timeout 15m env: - # Ensure tests don't try to interact with stdin CI: true + - name: Run resource E2E tests + run: | + cd cmd/hatchet-cli/cli + go test -tags e2e_cli \ + -run 'Test(Runs|Workflows|Worker|RateLimits|Scheduled|Cron|Webhooks)' \ + -v -timeout 10m + env: + HATCHET_CLI_PROFILE: local + - name: Show server logs on failure if: failure() run: | diff --git a/Taskfile.yaml b/Taskfile.yaml index e5df3a8799..dfa098f374 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -61,9 +61,21 @@ tasks: fmt-docs: dir: frontend/docs cmd: pnpm run prettier:fix && pnpm run prettier:check + sync-agent-instructions: + desc: Sync agent-instructions MDX files into hatchet-cli skill-assets references + cmds: + - | + src="frontend/docs/pages/agent-instructions" + dest="cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references" + for f in "$src"/*.mdx; do + name=$(basename "$f" .mdx) + cp "$f" "$dest/$name.md" + done + pre: aliases: [fmt] cmds: + - task: sync-agent-instructions - task: fmt-go - task: fmt-app - task: fmt-docs @@ -160,11 +172,12 @@ tasks: - task: generate-api-server - task: generate-api-client generate-docs: - deps: [venv] - dir: frontend/snippets - cmds: - - 'source {{.ROOT_DIR}}/.venv/bin/activate && python generate.py' - - task: fmt + deps: [venv] + dir: frontend/snippets + cmds: + - "source {{.ROOT_DIR}}/.venv/bin/activate && python generate.py" + - task: fmt + - task: sync-agent-instructions generate-certs: cmds: - bash ./hack/dev/generate-x509-certs.sh ./hack/dev/certs @@ -228,7 +241,7 @@ tasks: cmd: source .venv/bin/activate && pip install pre-commit && pre-commit install pre-commit-run: deps: [venv] - cmd: 'source .venv/bin/activate && pre-commit run --all-files || pre-commit run --all-files' + cmd: "source .venv/bin/activate && pre-commit run --all-files || pre-commit run --all-files" docs: cmds: - | diff --git a/api/v1/server/oas/transformers/workflow_run.go b/api/v1/server/oas/transformers/workflow_run.go index ad7c4b933b..bb46df1b1e 100644 --- a/api/v1/server/oas/transformers/workflow_run.go +++ b/api/v1/server/oas/transformers/workflow_run.go @@ -66,6 +66,11 @@ func ToCronWorkflowsFromSQLC(cron *sqlcv1.ListCronWorkflowsRow) *gen.CronWorkflo } } + input := make(map[string]interface{}) + if cron.Input != nil { + json.Unmarshal(cron.Input, &input) //nolint:errcheck + } + res := &gen.CronWorkflows{ Metadata: *toAPIMetadata(cron.ID_2, cron.CreatedAt_2.Time, cron.UpdatedAt_2.Time), WorkflowVersionId: cron.WorkflowVersionId.String(), @@ -78,6 +83,7 @@ func ToCronWorkflowsFromSQLC(cron *sqlcv1.ListCronWorkflowsRow) *gen.CronWorkflo Enabled: cron.Enabled, Method: gen.CronWorkflowsMethod(cron.Method), Priority: &cron.Priority, + Input: &input, } return res diff --git a/build/package/servers.dockerfile b/build/package/servers.dockerfile index 49506df286..71ed39af7c 100644 --- a/build/package/servers.dockerfile +++ b/build/package/servers.dockerfile @@ -22,26 +22,18 @@ COPY /cmd ./cmd RUN go generate ./... -# OpenAPI bundle environment -# ------------------------- -FROM node:22-alpine as build-openapi +# OpenAPI bundle environment (uses openapi-core only to avoid Redoc/React/styled-components) +# ---------------------------------------------------------------------------------------- +FROM node:22-alpine AS build-openapi WORKDIR /openapi COPY /api-contracts/openapi ./openapi +COPY /hack/oas/bundle-openapi.mjs ./bundle-openapi.mjs -RUN echo '{ \ - "name": "hack-for-redocly-cli-bug", \ - "version": "1.0.0", \ - "dependencies": { \ - "@redocly/cli": "latest" \ - }, \ - "overrides": { \ - "mobx-react": "9.2.0" \ - } \ - }' > package.json && \ - npm install -g npm@8.1 @redocly/cli@latest && \ - npx @redocly/cli@latest bundle ./openapi/openapi.yaml --output ./bin/oas/openapi.yaml --ext yaml && \ - rm package.json +RUN echo '{ "type": "module", "dependencies": { "@redocly/openapi-core": "2.14.7", "yaml": "2.7.0" } }' > package.json && \ + npm install && \ + node bundle-openapi.mjs && \ + rm -f package.json package-lock.json bundle-openapi.mjs # Go build environment # -------------------- diff --git a/cmd/hatchet-cli/cli/client.go b/cmd/hatchet-cli/cli/client.go index 84377f8b75..2c8047eae8 100644 --- a/cmd/hatchet-cli/cli/client.go +++ b/cmd/hatchet-cli/cli/client.go @@ -1,9 +1,13 @@ package cli import ( + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" "github.com/rs/zerolog" + "github.com/spf13/cobra" - "github.com/hatchet-dev/hatchet/pkg/client" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" clientconfig "github.com/hatchet-dev/hatchet/pkg/config/client" "github.com/hatchet-dev/hatchet/pkg/config/shared" @@ -11,7 +15,7 @@ import ( // NewClientFromProfile creates a new Hatchet client from a profile configuration. // It properly handles TLS settings, host/port, and authentication based on the profile. -func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger) (client.Client, error) { +func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger) (client.Client, error) { //nolint:staticcheck // Construct a ClientConfigFile from the profile configFile := &clientconfig.ClientConfigFile{ TenantId: profile.TenantId, @@ -26,5 +30,57 @@ func NewClientFromProfile(profile *profileconfig.Profile, logger *zerolog.Logger } // Create client with the config file and logger - return client.NewFromConfigFile(configFile, client.WithLogger(logger)) + return client.NewFromConfigFile(configFile, client.WithLogger(logger)) //nolint:staticcheck +} + +// clientCmdConfig holds CLI flag values used to create a Hatchet client. +type clientCmdConfig struct { + Profile string +} + +// readClientCmdConfig reads client-related flags from a command into a config struct. +func readClientCmdConfig(cmd *cobra.Command) clientCmdConfig { + profile, _ := cmd.Flags().GetString("profile") + return clientCmdConfig{Profile: profile} +} + +// clientFromCmd selects a profile and returns a Hatchet client. +// The profile is read from the --profile flag if present, otherwise selected interactively. +func clientFromCmd(cmd *cobra.Command) (string, client.Client) { //nolint:staticcheck + cfg := readClientCmdConfig(cmd) + + var selectedProfile string + if cfg.Profile != "" { + selectedProfile = cfg.Profile + } else { + selectedProfile = selectProfileForm(true) + if selectedProfile == "" { + selectedProfile = handleNoProfiles(cmd) + if selectedProfile == "" { + cli.Logger.Fatal("no profile selected or created") + } + } + } + + profile, err := cli.GetProfile(selectedProfile) + if err != nil { + cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err) + } + + nopLogger := zerolog.Nop() + hatchetClient, err := NewClientFromProfile(profile, &nopLogger) + if err != nil { + cli.Logger.Fatalf("could not create Hatchet client: %v", err) + } + + return selectedProfile, hatchetClient +} + +// clientTenantUUID parses and returns the tenant UUID from the client configuration. +func clientTenantUUID(hatchetClient client.Client) openapi_types.UUID { //nolint:staticcheck + parsed, err := uuid.Parse(hatchetClient.TenantId()) + if err != nil { + cli.Logger.Fatalf("invalid tenant ID: %v", err) + } + return parsed } diff --git a/cmd/hatchet-cli/cli/crons.go b/cmd/hatchet-cli/cli/crons.go new file mode 100644 index 0000000000..f18df2a4c3 --- /dev/null +++ b/cmd/hatchet-cli/cli/crons.go @@ -0,0 +1,391 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var cronCmd = &cobra.Command{ + Use: "cron", + Aliases: []string{"crons", "cron-job", "cron-jobs"}, + Short: "Manage cron jobs", + Long: `Commands for listing, inspecting, creating, enabling, disabling, and deleting cron jobs.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, +} + +var cronListCmd = &cobra.Command{ + Use: "list", + Short: "List cron jobs", + Long: `List cron jobs. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet cron list --profile local + + # JSON output + hatchet cron list -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeCronJobs + tuiM.currentView = tui.NewCronJobsView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + resp, err := hatchetClient.API().CronWorkflowListWithResponse(ctx, tenantUUID, &rest.CronWorkflowListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + cli.Logger.Fatalf("failed to list cron jobs: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var cronGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get cron job details", + Long: `Get details about a cron job. Outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cronID := args[0] + _, hatchetClient := clientFromCmd(cmd) + + cronUUID, err := uuid.Parse(cronID) + if err != nil { + cli.Logger.Fatalf("invalid cron job ID %q: %v", cronID, err) + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + resp, err := hatchetClient.API().WorkflowCronGetWithResponse(ctx, tenantUUID, cronUUID) + if err != nil { + cli.Logger.Fatalf("failed to get cron job: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("cron job not found (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var cronCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a cron job", + Long: `Create a new cron job. In --output json mode, all required flags must be set. Otherwise, launches an interactive form.`, + Example: ` # Interactive mode + hatchet cron create --profile local + + # JSON mode (required flags) + hatchet cron create --workflow my-workflow --cron "0 * * * *" --name my-cron -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + ctx := cmd.Context() + + workflowStr, _ := cmd.Flags().GetString("workflow") + cronExpr, _ := cmd.Flags().GetString("cron") + cronName, _ := cmd.Flags().GetString("name") + inputStr, _ := cmd.Flags().GetString("input") + inputFile, _ := cmd.Flags().GetString("input-file") + + if !isJSON { + // Interactive mode: show workflow selector first, then remaining fields + if workflowStr == "" { + workflowStr = promptSelectWorkflow(ctx, hatchetClient) + if workflowStr == "" { + cli.Logger.Fatal("no workflow selected") + } + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Cron expression"). + Value(&cronExpr). + Placeholder("0 * * * *"), + ), + huh.NewGroup( + huh.NewInput(). + Title("Cron job name (optional)"). + Value(&cronName). + Placeholder("my-cron"), + ), + huh.NewGroup( + huh.NewInput(). + Title("Input JSON (optional)"). + Value(&inputStr). + Placeholder("{}"), + ), + ).WithTheme(styles.HatchetTheme()) + if err := form.Run(); err != nil { + cli.Logger.Fatalf("form cancelled: %v", err) + } + } + + if workflowStr == "" { + cli.Logger.Fatal("--workflow is required") + } + if cronExpr == "" { + cli.Logger.Fatal("--cron is required") + } + + // Build input map + inputData := map[string]interface{}{} + if inputFile != "" { + data, err := os.ReadFile(inputFile) + if err != nil { + cli.Logger.Fatalf("failed to read --input-file: %v", err) + } + if err := json.Unmarshal(data, &inputData); err != nil { + cli.Logger.Fatalf("failed to parse --input-file as JSON: %v", err) + } + } else if inputStr != "" { + if err := json.Unmarshal([]byte(inputStr), &inputData); err != nil { + cli.Logger.Fatalf("failed to parse --input as JSON: %v", err) + } + } + + tenantUUID := clientTenantUUID(hatchetClient) + + // Resolve workflow name (the create endpoint uses name as path param, not UUID) + workflowName, err := resolveWorkflowName(ctx, hatchetClient, workflowStr) + if err != nil { + cli.Logger.Fatalf("could not resolve workflow: %v", err) + } + + if cronName == "" { + cronName = workflowName + "-cron" + } + + resp, err := hatchetClient.API().CronWorkflowTriggerCreateWithResponse(ctx, tenantUUID, workflowName, rest.CronWorkflowTriggerCreateJSONRequestBody{ + CronExpression: cronExpr, + CronName: cronName, + Input: inputData, + AdditionalMetadata: map[string]interface{}{}, + }) + if err != nil { + cli.Logger.Fatalf("failed to create cron job: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d): %s", resp.StatusCode(), string(resp.Body)) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Created cron job: %s", resp.JSON200.Metadata.Id))) + } + }, +} + +var cronDeleteCmd = &cobra.Command{ + Use: "delete [cron-id]", + Short: "Delete a cron job", + Long: `Delete a cron job by ID. Omit the ID to pick from a list interactively. Use --yes to skip confirmation.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + var cronID string + if len(args) == 1 { + cronID = args[0] + } else if !isJSON { + // No ID provided — show an interactive selector + limit := int64(100) + listResp, listErr := hatchetClient.API().CronWorkflowListWithResponse(ctx, tenantUUID, &rest.CronWorkflowListParams{ + Limit: &limit, + }) + if listErr != nil { + cli.Logger.Fatalf("failed to list cron jobs: %v", listErr) + } + if listResp.JSON200 == nil || listResp.JSON200.Rows == nil || len(*listResp.JSON200.Rows) == 0 { + cli.Logger.Fatal("no cron jobs found") + } + + var options []huh.Option[string] + for _, cron := range *listResp.JSON200.Rows { + name := cron.Metadata.Id + if cron.Name != nil && *cron.Name != "" { + name = *cron.Name + } + label := fmt.Sprintf("%s (%s)", name, cron.Cron) + options = append(options, huh.NewOption(label, cron.Metadata.Id)) + } + + height := len(options) + if height > 10 { + height = 10 + } + form := huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Select a cron job to delete"). + Options(options...). + Height(height). + Value(&cronID), + )).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil { + cli.Logger.Fatalf("selection cancelled: %v", formErr) + } + } else { + cli.Logger.Fatal("cron job ID is required in JSON mode") + } + + cronUUID, err := uuid.Parse(cronID) + if err != nil { + cli.Logger.Fatalf("invalid cron job ID %q: %v", cronID, err) + } + + if !isJSON && !yes { + if !confirmAction(fmt.Sprintf("Delete cron job '%s'?", shortID(cronID))) { + fmt.Println("Aborted.") + return + } + } + + resp, err := hatchetClient.API().WorkflowCronDeleteWithResponse(ctx, tenantUUID, cronUUID) + if err != nil { + cli.Logger.Fatalf("failed to delete cron job: %v", err) + } + if resp.StatusCode() >= 400 { + cli.Logger.Fatalf("failed to delete cron job (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(map[string]interface{}{"deleted": true, "id": cronID}) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Deleted cron job: %s", cronID))) + } + }, +} + +var cronEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a cron job", + Long: `Enable a cron job by ID.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cronID := args[0] + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + + cronUUID, err := uuid.Parse(cronID) + if err != nil { + cli.Logger.Fatalf("invalid cron job ID %q: %v", cronID, err) + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + enabled := true + resp, err := hatchetClient.API().WorkflowCronUpdateWithResponse(ctx, tenantUUID, cronUUID, rest.WorkflowCronUpdateJSONRequestBody{ + Enabled: &enabled, + }) + if err != nil { + cli.Logger.Fatalf("failed to enable cron job: %v", err) + } + if resp.StatusCode() >= 400 { + cli.Logger.Fatalf("failed to enable cron job (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(map[string]interface{}{"enabled": true, "id": cronID}) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Enabled cron job: %s", cronID))) + } + }, +} + +var cronDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a cron job", + Long: `Disable a cron job by ID. Use --yes to skip confirmation.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cronID := args[0] + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + + cronUUID, err := uuid.Parse(cronID) + if err != nil { + cli.Logger.Fatalf("invalid cron job ID %q: %v", cronID, err) + } + + if !isJSON && !yes { + if !confirmAction(fmt.Sprintf("Disable cron job '%s'?", shortID(cronID))) { + fmt.Println("Aborted.") + return + } + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + enabled := false + resp, err := hatchetClient.API().WorkflowCronUpdateWithResponse(ctx, tenantUUID, cronUUID, rest.WorkflowCronUpdateJSONRequestBody{ + Enabled: &enabled, + }) + if err != nil { + cli.Logger.Fatalf("failed to disable cron job: %v", err) + } + if resp.StatusCode() >= 400 { + cli.Logger.Fatalf("failed to disable cron job (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(map[string]interface{}{"enabled": false, "id": cronID}) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Disabled cron job: %s", cronID))) + } + }, +} + +func init() { + rootCmd.AddCommand(cronCmd) + cronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronEnableCmd, cronDisableCmd) + + cronCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + cronCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + + cronListCmd.Flags().Int64("limit", 50, "Number of results to return") + cronListCmd.Flags().Int64("offset", 0, "Offset for pagination") + + cronCreateCmd.Flags().StringP("workflow", "w", "", "Workflow name or ID") + cronCreateCmd.Flags().StringP("cron", "c", "", "Cron expression (e.g. '0 * * * *')") + cronCreateCmd.Flags().StringP("name", "n", "", "Cron job name") + cronCreateCmd.Flags().StringP("input", "i", "", "Input JSON string") + cronCreateCmd.Flags().String("input-file", "", "Path to a JSON file for input") + + cronDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + cronDisableCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} diff --git a/cmd/hatchet-cli/cli/crons_e2e_test.go b/cmd/hatchet-cli/cli/crons_e2e_test.go new file mode 100644 index 0000000000..5afb24cee2 --- /dev/null +++ b/cmd/hatchet-cli/cli/crons_e2e_test.go @@ -0,0 +1,124 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestCronListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("cron", "list") + + var result struct { + Rows []map[string]interface{} `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal cron list output: %v\nOutput: %s", err, out) + } + + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } + + // If rows exist, validate expected fields + for i, row := range result.Rows { + if row["cron"] == nil && row["cronExpression"] == nil { + t.Logf("row[%d]: cron expression field not found; available fields: %v", i, keys(row)) + } + } +} + +func TestCronCreateDisableDeleteJSON(t *testing.T) { + workflowID := os.Getenv("HATCHET_TEST_WORKFLOW_ID") + if workflowID == "" { + t.Skip("HATCHET_TEST_WORKFLOW_ID not set; skipping cron create/disable/delete test") + } + + h := testharness.New(t) + + // Create a cron job + createOut := h.RunJSON("cron", "create", + "--workflow", workflowID, + "--cron", "0 * * * *", + "--name", "e2e-test-cron", + ) + + var created struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(createOut, &created); err != nil { + t.Fatalf("failed to unmarshal cron create output: %v\nOutput: %s", err, createOut) + } + if created.Metadata.ID == "" { + t.Fatal("expected metadata.id in cron create response") + } + + cronID := created.Metadata.ID + t.Logf("Created cron job: %s", cronID) + + // Disable the cron job + disableOut := h.RunJSON("cron", "disable", "--yes", cronID) + + var disabled struct { + Enabled bool `json:"enabled"` + ID string `json:"id"` + } + if err := json.Unmarshal(disableOut, &disabled); err != nil { + t.Fatalf("failed to unmarshal cron disable output: %v\nOutput: %s", err, disableOut) + } + if disabled.Enabled { + t.Errorf("expected enabled=false after disable, got: %s", disableOut) + } + + // Delete the cron job + deleteOut := h.RunJSON("cron", "delete", "--yes", cronID) + + var deleted struct { + Deleted bool `json:"deleted"` + ID string `json:"id"` + } + if err := json.Unmarshal(deleteOut, &deleted); err != nil { + t.Fatalf("failed to unmarshal cron delete output: %v\nOutput: %s", err, deleteOut) + } + if !deleted.Deleted { + t.Errorf("expected deleted=true, got: %s", deleteOut) + } + if deleted.ID != cronID { + t.Errorf("expected deleted id %q, got %q", cronID, deleted.ID) + } + + t.Logf("Deleted cron job: %s", cronID) +} + +func TestCronTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("cron", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Cron Jobs", "Cron", "Expression") { + t.Errorf("expected TUI to show 'Cron Jobs' header; got:\n%s", content) + } +} + +// keys returns the map keys as a slice for logging purposes. +func keys(m map[string]interface{}) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} diff --git a/cmd/hatchet-cli/cli/profile.go b/cmd/hatchet-cli/cli/profile.go index 66f102513a..ad08150c68 100644 --- a/cmd/hatchet-cli/cli/profile.go +++ b/cmd/hatchet-cli/cli/profile.go @@ -319,6 +319,7 @@ func init() { profileRemoveCmd.Flags().StringP("name", "n", "", "Name of the profile to remove (prompted if not provided)") // Add flags to profile show command + profileShowCmd.Flags().StringP("name", "n", "", "Name of the profile to show (prompted if not provided)") profileShowCmd.Flags().Bool("show-token", false, "Show the full token (default: masked)") // Add flags to profile update command diff --git a/cmd/hatchet-cli/cli/rate_limits.go b/cmd/hatchet-cli/cli/rate_limits.go new file mode 100644 index 0000000000..4c703dd3a2 --- /dev/null +++ b/cmd/hatchet-cli/cli/rate_limits.go @@ -0,0 +1,80 @@ +package cli + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var rateLimitsCmd = &cobra.Command{ + Use: "rate-limits", + Aliases: []string{"rate-limit", "rl", "rls"}, + Short: "Manage rate limits", + Long: `Commands for listing and viewing rate limit usage.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, +} + +var rateLimitsListCmd = &cobra.Command{ + Use: "list", + Short: "List rate limits", + Long: `List rate limits. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet rate-limits list --profile local + + # JSON output + hatchet rate-limits list -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeRateLimits + tuiM.currentView = tui.NewRateLimitsView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + searchStr, _ := cmd.Flags().GetString("search") + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + params := &rest.RateLimitListParams{ + Limit: &limit, + Offset: &offset, + } + if searchStr != "" { + params.Search = &searchStr + } + + resp, err := hatchetClient.API().RateLimitListWithResponse(ctx, tenantUUID, params) + if err != nil { + cli.Logger.Fatalf("failed to list rate limits: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +func init() { + rootCmd.AddCommand(rateLimitsCmd) + rateLimitsCmd.AddCommand(rateLimitsListCmd) + + rateLimitsCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + rateLimitsCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + + rateLimitsListCmd.Flags().StringP("search", "s", "", "Search rate limits by key") + rateLimitsListCmd.Flags().Int64("limit", 50, "Number of results to return") + rateLimitsListCmd.Flags().Int64("offset", 0, "Offset for pagination") +} diff --git a/cmd/hatchet-cli/cli/rate_limits_e2e_test.go b/cmd/hatchet-cli/cli/rate_limits_e2e_test.go new file mode 100644 index 0000000000..2ba6060cb9 --- /dev/null +++ b/cmd/hatchet-cli/cli/rate_limits_e2e_test.go @@ -0,0 +1,55 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestRateLimitsListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("rate-limits", "list") + + var result struct { + Rows []struct { + Key string `json:"key"` + Value int `json:"value"` + LimitValue int `json:"limitValue"` + } `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal rate-limits list output: %v\nOutput: %s", err, out) + } + + // rows may be empty, but the field must exist + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } + + // If there are rows, validate they have the expected fields + for i, row := range result.Rows { + if row.Key == "" { + t.Errorf("row[%d]: expected non-empty 'key' field", i) + } + } +} + +func TestRateLimitsTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("rate-limits", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Rate Limits", "rate-limits", "rate_limits") { + t.Errorf("expected TUI to show 'Rate Limits' header; got:\n%s", content) + } +} diff --git a/cmd/hatchet-cli/cli/runs.go b/cmd/hatchet-cli/cli/runs.go new file mode 100644 index 0000000000..6b96fe21dc --- /dev/null +++ b/cmd/hatchet-cli/cli/runs.go @@ -0,0 +1,1138 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "sort" + "strconv" + "strings" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var runsCmd = &cobra.Command{ + Use: "runs", + Aliases: []string{"run"}, + Short: "Manage runs", + Long: `Commands for listing, inspecting, cancelling, replaying, and viewing logs/events for runs.`, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var runsListCmd = &cobra.Command{ + Use: "list", + Short: "List runs", + Long: `List runs. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON to stdout.`, + Example: ` # Launch interactive TUI (default) + hatchet runs list --profile local + + # JSON output with filters + hatchet runs list -o json --since 24h --status FAILED + hatchet runs list -o json --since 1h --workflow my-workflow --limit 100`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + model := newTUIModel(selectedProfile, hatchetClient) + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + // JSON mode: parse flags and call API + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + sinceStr, _ := cmd.Flags().GetString("since") + sinceTime, err := parseSinceDuration(sinceStr) + if err != nil { + cli.Logger.Fatalf("invalid --since value: %v", err) + } + + untilStr, _ := cmd.Flags().GetString("until") + var untilPtr *time.Time + if untilStr != "" { + var until time.Time + until, err = parseSinceDuration(untilStr) + if err != nil { + cli.Logger.Fatalf("invalid --until value: %v", err) + } + untilPtr = &until + } + + workflowStr, _ := cmd.Flags().GetString("workflow") + var workflowUUIDs *[]openapi_types.UUID + if workflowStr != "" { + var wfUUID openapi_types.UUID + wfUUID, err = resolveWorkflowID(ctx, hatchetClient, workflowStr) + if err != nil { + cli.Logger.Fatalf("could not resolve workflow: %v", err) + } + workflowUUIDs = &[]openapi_types.UUID{wfUUID} + } + + statusStrs, _ := cmd.Flags().GetStringSlice("status") + statuses, err := parseStatuses(statusStrs) + if err != nil { + cli.Logger.Fatalf("%v", err) + } + var statusesPtr *[]rest.V1TaskStatus + if len(statuses) > 0 { + statusesPtr = &statuses + } + + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + onlyTasks, _ := cmd.Flags().GetBool("only-tasks") + + params := &rest.V1WorkflowRunListParams{ + Since: sinceTime, + Until: untilPtr, + Statuses: statusesPtr, + Limit: &limit, + Offset: &offset, + OnlyTasks: onlyTasks, + } + if workflowUUIDs != nil { + params.WorkflowIds = workflowUUIDs + } + + resp, err := hatchetClient.API().V1WorkflowRunListWithResponse(ctx, tenantUUID, params) + if err != nil { + cli.Logger.Fatalf("failed to list runs: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var runsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Inspect a run", + Long: `Get details about a run. Without --output json, launches the interactive TUI navigated to the run. With --output json, outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Example: ` # Launch TUI for a specific run + hatchet runs get 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # JSON output + hatchet runs get 8ff4f149-099e-4c16-a8d1-0535f8c79b83 -o json`, + Run: func(cmd *cobra.Command, args []string) { + runID := args[0] + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + model := tuiModelWithInitialRun{ + tuiModel: newTUIModel(selectedProfile, hatchetClient), + initialRunID: runID, + } + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + // JSON mode + ctx := cmd.Context() + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + resp, err := hatchetClient.API().V1WorkflowRunGetWithResponse(ctx, runUUID) + if err != nil { + cli.Logger.Fatalf("failed to get run: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("run not found (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var runsCancelCmd = &cobra.Command{ + Use: "cancel [run-id]", + Short: "Cancel a run or bulk cancel by filter", + Long: `Cancel a specific run by ID, or cancel multiple runs matching filter criteria. + +If a run ID is provided, cancels that specific run (task or DAG). +If no run ID is provided, requires --since flag and cancels runs matching the filter.`, + Args: cobra.MaximumNArgs(1), + Example: ` # Cancel a specific run + hatchet runs cancel 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # Bulk cancel by filter + hatchet runs cancel --since 1h --status FAILED --profile local + + # Bulk cancel JSON mode (no confirmation) + hatchet runs cancel --since 24h --workflow my-workflow -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + if len(args) > 0 { + // Single run cancel + runID := args[0] + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + if !isJSON && !yes { + // Try to get run name for confirmation + runName := shortID(runID) + runResp, _ := hatchetClient.API().V1WorkflowRunGetWithResponse(ctx, runUUID) + if runResp != nil && runResp.JSON200 != nil { + runName = runResp.JSON200.Run.DisplayName + } + if !confirmAction(fmt.Sprintf("Cancel run '%s'?", runName)) { + fmt.Println("Aborted.") + return + } + } + + externalID := runUUID + resp, err := hatchetClient.API().V1TaskCancelWithResponse(ctx, tenantUUID, rest.V1CancelTaskRequest{ + ExternalIds: &[]openapi_types.UUID{externalID}, + }) + if err != nil { + cli.Logger.Fatalf("failed to cancel run: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + count := 0 + if resp.JSON200.Ids != nil { + count = len(*resp.JSON200.Ids) + } + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Cancelled %d run(s)", count))) + } + return + } + + // Bulk cancel via filters + sinceStr, _ := cmd.Flags().GetString("since") + if sinceStr == "" { + cli.Logger.Fatal("--since is required for bulk cancel (or provide a run ID as argument)") + } + + filter, listParams, err := buildFilterAndParams(ctx, cmd, hatchetClient) + if err != nil { + cli.Logger.Fatalf("%v", err) + } + + if !isJSON && !yes { + count := countMatchingRuns(ctx, hatchetClient, tenantUUID, listParams) + if count == 0 { + fmt.Println(styles.Muted.Render("No runs match your filters.")) + return + } + if !confirmAction(fmt.Sprintf("This will cancel %d runs matching your filters. Continue?", count)) { + fmt.Println("Aborted.") + return + } + } + + resp, err := hatchetClient.API().V1TaskCancelWithResponse(ctx, tenantUUID, rest.V1CancelTaskRequest{ + Filter: filter, + }) + if err != nil { + cli.Logger.Fatalf("failed to cancel runs: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + count := 0 + if resp.JSON200.Ids != nil { + count = len(*resp.JSON200.Ids) + } + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Cancelled %d run(s)", count))) + } + }, +} + +var runsReplayCmd = &cobra.Command{ + Use: "replay [run-id]", + Short: "Replay a run or bulk replay by filter", + Long: `Replay a specific run by ID, or replay multiple runs matching filter criteria. + +If a run ID is provided, replays that specific run (task or DAG). +If no run ID is provided, requires --since flag and replays runs matching the filter.`, + Args: cobra.MaximumNArgs(1), + Example: ` # Replay a specific run + hatchet runs replay 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # Bulk replay by filter + hatchet runs replay --since 1h --status FAILED --profile local + + # Bulk replay JSON mode (no confirmation) + hatchet runs replay --since 24h -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + if len(args) > 0 { + // Single run replay + runID := args[0] + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + if !isJSON && !yes { + runName := shortID(runID) + runResp, _ := hatchetClient.API().V1WorkflowRunGetWithResponse(ctx, runUUID) + if runResp != nil && runResp.JSON200 != nil { + runName = runResp.JSON200.Run.DisplayName + } + if !confirmAction(fmt.Sprintf("Replay run '%s'?", runName)) { + fmt.Println("Aborted.") + return + } + } + + externalID := runUUID + resp, err := hatchetClient.API().V1TaskReplayWithResponse(ctx, tenantUUID, rest.V1ReplayTaskRequest{ + ExternalIds: &[]openapi_types.UUID{externalID}, + }) + if err != nil { + cli.Logger.Fatalf("failed to replay run: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + count := 0 + if resp.JSON200.Ids != nil { + count = len(*resp.JSON200.Ids) + } + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Replayed %d run(s)", count))) + } + return + } + + // Bulk replay via filters + sinceStr, _ := cmd.Flags().GetString("since") + if sinceStr == "" { + cli.Logger.Fatal("--since is required for bulk replay (or provide a run ID as argument)") + } + + filter, listParams, err := buildFilterAndParams(ctx, cmd, hatchetClient) + if err != nil { + cli.Logger.Fatalf("%v", err) + } + + if !isJSON && !yes { + count := countMatchingRuns(ctx, hatchetClient, tenantUUID, listParams) + if count == 0 { + fmt.Println(styles.Muted.Render("No runs match your filters.")) + return + } + if !confirmAction(fmt.Sprintf("This will replay %d runs matching your filters. Continue?", count)) { + fmt.Println("Aborted.") + return + } + } + + resp, err := hatchetClient.API().V1TaskReplayWithResponse(ctx, tenantUUID, rest.V1ReplayTaskRequest{ + Filter: filter, + }) + if err != nil { + cli.Logger.Fatalf("failed to replay runs: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + count := 0 + if resp.JSON200.Ids != nil { + count = len(*resp.JSON200.Ids) + } + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Replayed %d run(s)", count))) + } + }, +} + +var runsLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Print logs from a run", + Long: `Fetch and print logs from a run to stdout, sorted by timestamp. Works for both task runs and DAG workflow runs.`, + Args: cobra.ExactArgs(1), + Example: ` # Print logs to stdout + hatchet runs logs 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # Show only the last 50 lines + hatchet runs logs 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --tail 50 + + # Show logs from the last 5 minutes + hatchet runs logs 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --since 5m + + # Follow (poll for new logs) + hatchet runs logs 8ff4f149-099e-4c16-a8d1-0535f8c79b83 -f + + # JSON output + hatchet runs logs 8ff4f149-099e-4c16-a8d1-0535f8c79b83 -o json | jq .`, + Run: func(cmd *cobra.Command, args []string) { + runID := args[0] + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + + ctx := cmd.Context() + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + tail, _ := cmd.Flags().GetInt64("tail") + sinceStr, _ := cmd.Flags().GetString("since") + follow, _ := cmd.Flags().GetBool("follow") + + var sinceTime *time.Time + if sinceStr != "" { + var t time.Time + t, err = parseSinceDuration(sinceStr) + if err != nil { + cli.Logger.Fatalf("invalid --since value: %v", err) + } + sinceTime = &t + } + + type logEntry struct { + Task string `json:"task"` + Timestamp time.Time `json:"timestamp"` + Level string `json:"level,omitempty"` + Message string `json:"message"` + } + + // fetchLogs fetches logs for the run. pollLimit is set during follow-mode polling + // to avoid dropping logs between polls (nil means apply --tail logic instead). + fetchLogs := func(since *time.Time, pollLimit *int64) ([]logEntry, error) { + // Try to fetch as a workflow run (DAG) + runResp, fetchErr := hatchetClient.API().V1WorkflowRunGetWithResponse(ctx, runUUID) + if fetchErr == nil && runResp.JSON200 != nil { + // DAG run: fetch all logs per task then apply --tail to the merged list, + // rather than limiting per-task (which would give wrong "newest N overall"). + params := &rest.V1LogLineListParams{Since: since} + if pollLimit != nil { + params.Limit = pollLimit + } + var allLogs []logEntry + for _, task := range runResp.JSON200.Tasks { + taskUUID, parseErr := uuid.Parse(task.Metadata.Id) + if parseErr != nil { + continue + } + logsResp, logsErr := hatchetClient.API().V1LogLineListWithResponse( + ctx, + taskUUID, + params, + ) + if logsErr != nil || logsResp.JSON200 == nil || logsResp.JSON200.Rows == nil { + continue + } + for _, logLine := range *logsResp.JSON200.Rows { + level := "" + if logLine.Level != nil { + level = string(*logLine.Level) + } + allLogs = append(allLogs, logEntry{ + Task: task.DisplayName, + Timestamp: logLine.CreatedAt, + Level: level, + Message: logLine.Message, + }) + } + } + sort.Slice(allLogs, func(i, j int) bool { + return allLogs[i].Timestamp.Before(allLogs[j].Timestamp) + }) + // Apply --tail to merged result after sorting + if tail > 0 && pollLimit == nil && int64(len(allLogs)) > tail { + allLogs = allLogs[int64(len(allLogs))-tail:] + } + return allLogs, nil + } + + // Fall back: treat run ID as a task ID directly (single task run). + // Use DESC ordering when --tail is set so the API returns the newest N lines, + // then reverse for chronological display. + var params *rest.V1LogLineListParams + if tail > 0 && pollLimit == nil { + descDir := rest.V1LogLineOrderByDirectionDESC + params = &rest.V1LogLineListParams{ + Since: since, + Limit: &tail, + OrderByDirection: &descDir, + } + } else { + params = &rest.V1LogLineListParams{Since: since} + if pollLimit != nil { + params.Limit = pollLimit + } + } + logsResp, logsErr := hatchetClient.API().V1LogLineListWithResponse( + ctx, + runUUID, + params, + ) + if logsErr != nil { + return nil, fmt.Errorf("failed to fetch logs: %w", logsErr) + } + if logsResp.JSON200 == nil || logsResp.JSON200.Rows == nil { + return nil, nil + } + var allLogs []logEntry + for _, logLine := range *logsResp.JSON200.Rows { + level := "" + if logLine.Level != nil { + level = string(*logLine.Level) + } + allLogs = append(allLogs, logEntry{ + Task: "", + Timestamp: logLine.CreatedAt, + Level: level, + Message: logLine.Message, + }) + } + // Reverse DESC results back to chronological order + if tail > 0 && pollLimit == nil { + for i, j := 0, len(allLogs)-1; i < j; i, j = i+1, j-1 { + allLogs[i], allLogs[j] = allLogs[j], allLogs[i] + } + } + return allLogs, nil + } + + printLogEntries := func(logs []logEntry) { + for _, l := range logs { + ts := l.Timestamp.Format("15:04:05.000") + if l.Task != "" { + if l.Level != "" { + fmt.Printf("[%s] %s %-8s %s\n", l.Task, ts, l.Level, l.Message) + } else { + fmt.Printf("[%s] %s %s\n", l.Task, ts, l.Message) + } + } else { + if l.Level != "" { + fmt.Printf("%s %-8s %s\n", ts, l.Level, l.Message) + } else { + fmt.Printf("%s %s\n", ts, l.Message) + } + } + } + } + + // Initial fetch + allLogs, err := fetchLogs(sinceTime, nil) + if err != nil { + cli.Logger.Fatalf("%v", err) + } + + if isJSON { + printJSON(allLogs) + return + } + + if len(allLogs) == 0 && !follow { + fmt.Println("No logs found for this run.") + return + } + + printLogEntries(allLogs) + + if !follow { + return + } + + // Determine the timestamp to use as the "since" cursor for polling + var lastTimestamp time.Time + if len(allLogs) > 0 { + lastTimestamp = allLogs[len(allLogs)-1].Timestamp + } else { + lastTimestamp = time.Now() + } + + // Poll for new logs + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + for { + select { + case <-ctx.Done(): + return + case <-sigCh: + return + case <-ticker.C: + // Add a small buffer to avoid re-fetching the last line. + // Use a high explicit limit so fast-producing tasks don't drop logs. + pollSince := lastTimestamp.Add(time.Millisecond) + highLimit := int64(10000) + newLogs, err := fetchLogs(&pollSince, &highLimit) + if err != nil || len(newLogs) == 0 { + continue + } + printLogEntries(newLogs) + lastTimestamp = newLogs[len(newLogs)-1].Timestamp + } + } + }, +} + +var runsListChildrenCmd = &cobra.Command{ + Use: "list-children ", + Aliases: []string{"children"}, + Short: "List children of a run", + Long: `List the children of a run. + +If the run is a DAG, lists the constituent tasks that belong to it. +If the run is a task, lists spawned child workflow runs.`, + Args: cobra.ExactArgs(1), + Example: ` # List children of a DAG run + hatchet runs list-children 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # List spawned child runs of a task + hatchet runs list-children abc12345-... --profile local + + # JSON output + hatchet runs list-children 8ff4f149-099e-4c16-a8d1-0535f8c79b83 -o json`, + Run: func(cmd *cobra.Command, args []string) { + runID := args[0] + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + // Detect run type: try V1TaskGet first. If it succeeds the ID is a task; + // if it fails or returns nothing, fall through to treat it as a DAG run. + taskResp, taskErr := hatchetClient.API().V1TaskGetWithResponse(ctx, runUUID, &rest.V1TaskGetParams{}) + if taskErr == nil && taskResp.JSON200 != nil { + // It's a task — list spawned child workflow runs using the task's external ID. + taskExternalID := taskResp.JSON200.TaskExternalId + childRunsResp, childErr := hatchetClient.API().V1WorkflowRunListWithResponse(ctx, tenantUUID, &rest.V1WorkflowRunListParams{ + ParentTaskExternalId: &taskExternalID, + Limit: &limit, + Offset: &offset, + }) + if childErr != nil { + cli.Logger.Fatalf("failed to list child runs: %v", childErr) + } + if childRunsResp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d): %s", childRunsResp.StatusCode(), string(childRunsResp.Body)) + } + + if isJSON { + printJSON(childRunsResp.JSON200) + return + } + + rows := childRunsResp.JSON200.Rows + if len(rows) == 0 { + fmt.Println("No child runs found.") + return + } + fmt.Printf("%-38s %-25s %-12s %s\n", "Run ID", "Name", "Status", "Duration") + for _, run := range rows { + dur := "" + if run.Duration != nil { + dur = fmt.Sprintf("%dms", *run.Duration) + } + fmt.Printf("%-38s %-25s %-12s %s\n", + run.Metadata.Id, + run.DisplayName, + string(run.Status), + dur, + ) + } + return + } + + // Not a task (or task fetch failed) — treat as a DAG run and list its constituent tasks. + dagTasksResp, err := hatchetClient.API().V1DagListTasksWithResponse(ctx, &rest.V1DagListTasksParams{ + DagIds: []openapi_types.UUID{runUUID}, + Tenant: tenantUUID, + }) + if err != nil { + cli.Logger.Fatalf("failed to list children: %v", err) + } + if dagTasksResp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d): %s", dagTasksResp.StatusCode(), string(dagTasksResp.Body)) + } + + var tasks []rest.V1TaskSummary + for _, dag := range *dagTasksResp.JSON200 { + if dag.Children != nil { + tasks = append(tasks, *dag.Children...) + } + } + + if isJSON { + printJSON(map[string]any{"rows": tasks}) + return + } + + if len(tasks) == 0 { + fmt.Println("No tasks found for this DAG run.") + return + } + fmt.Printf("%-38s %-25s %-12s %s\n", "Task ID", "Name", "Status", "Duration") + for _, task := range tasks { + dur := "" + if task.Duration != nil { + dur = fmt.Sprintf("%dms", *task.Duration) + } + fmt.Printf("%-38s %-25s %-12s %s\n", + task.Metadata.Id, + task.DisplayName, + string(task.Status), + dur, + ) + } + }, +} + +var runsEventsCmd = &cobra.Command{ + Use: "events ", + Short: "Print events from a run", + Long: `Fetch and print lifecycle events from a run to stdout. Works for both task runs and DAG workflow runs.`, + Args: cobra.ExactArgs(1), + Example: ` # Print events to stdout + hatchet runs events 8ff4f149-099e-4c16-a8d1-0535f8c79b83 --profile local + + # JSON output + hatchet runs events 8ff4f149-099e-4c16-a8d1-0535f8c79b83 -o json | jq .`, + Run: func(cmd *cobra.Command, args []string) { + runID := args[0] + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + + ctx := cmd.Context() + runUUID, err := uuid.Parse(runID) + if err != nil { + cli.Logger.Fatalf("invalid run ID %q: %v", runID, err) + } + + var events []rest.V1TaskEvent + + // Try as a workflow run (DAG) first — TaskEvents are embedded in the run details + runResp, err := hatchetClient.API().V1WorkflowRunGetWithResponse(ctx, runUUID) + if err == nil && runResp.JSON200 != nil && len(runResp.JSON200.TaskEvents) > 0 { + events = runResp.JSON200.TaskEvents + } else { + // Fall back: treat run ID as a task ID and use the task events endpoint + limit := int64(1000) + offset := int64(0) + evtResp, err := hatchetClient.API().V1TaskEventListWithResponse( + ctx, + runUUID, + &rest.V1TaskEventListParams{ + Limit: &limit, + Offset: &offset, + }, + ) + if err != nil { + cli.Logger.Fatalf("failed to fetch events: %v", err) + } + if evtResp.JSON200 != nil && evtResp.JSON200.Rows != nil { + events = *evtResp.JSON200.Rows + } + } + + if isJSON { + printJSON(events) + return + } + + if len(events) == 0 { + fmt.Println("No events found for this run.") + return + } + + for _, evt := range events { + ts := evt.Timestamp.Format("15:04:05.000") + taskName := "" + if evt.TaskDisplayName != nil { + taskName = *evt.TaskDisplayName + } + fmt.Printf("%s %-30s %-25s %s\n", ts, string(evt.EventType), taskName, evt.Message) + } + }, +} + +func init() { + rootCmd.AddCommand(runsCmd) + runsCmd.AddCommand(runsListCmd, runsGetCmd, runsCancelCmd, runsReplayCmd, runsLogsCmd, runsEventsCmd, runsListChildrenCmd) + + // Persistent flags on parent (inherited by all subcommands) + runsCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + runsCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + + // runs list flags + runsListCmd.Flags().StringP("since", "s", "24h", "Show runs since this duration ago (e.g. 1h, 24h, 7d)") + runsListCmd.Flags().String("until", "", "Show runs until this duration ago (e.g. 30m)") + runsListCmd.Flags().StringP("workflow", "w", "", "Filter by workflow name or ID") + runsListCmd.Flags().StringSlice("status", nil, "Filter by status (QUEUED,RUNNING,COMPLETED,FAILED,CANCELLED)") + runsListCmd.Flags().Int64("limit", 50, "Number of results to return") + runsListCmd.Flags().Int64("offset", 0, "Offset for pagination") + runsListCmd.Flags().Bool("only-tasks", false, "Show only task runs, not DAG runs") + + // runs cancel flags + runsCancelCmd.Flags().StringP("since", "s", "", "Cancel runs since this duration ago (e.g. 1h, 24h) [required for bulk cancel]") + runsCancelCmd.Flags().String("until", "", "Cancel runs until this duration ago") + runsCancelCmd.Flags().StringP("workflow", "w", "", "Filter by workflow name or ID") + runsCancelCmd.Flags().StringSlice("status", nil, "Filter by status (QUEUED,RUNNING,COMPLETED,FAILED,CANCELLED)") + runsCancelCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + // runs replay flags (same as cancel) + runsReplayCmd.Flags().StringP("since", "s", "", "Replay runs since this duration ago (e.g. 1h, 24h) [required for bulk replay]") + runsReplayCmd.Flags().String("until", "", "Replay runs until this duration ago") + runsReplayCmd.Flags().StringP("workflow", "w", "", "Filter by workflow name or ID") + runsReplayCmd.Flags().StringSlice("status", nil, "Filter by status (QUEUED,RUNNING,COMPLETED,FAILED,CANCELLED)") + runsReplayCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + // runs list-children flags + runsListChildrenCmd.Flags().Int64("limit", 50, "Number of results to return (for task children)") + runsListChildrenCmd.Flags().Int64("offset", 0, "Offset for pagination (for task children)") + + // runs logs flags + runsLogsCmd.Flags().Int64("tail", 0, "Number of most recent log lines to show (0 = all)") + runsLogsCmd.Flags().String("since", "", "Only show logs newer than this duration ago (e.g. 5m, 1h, 24h)") + runsLogsCmd.Flags().BoolP("follow", "f", false, "Poll for new logs and print them as they arrive (Ctrl+C to stop)") +} + +// isJSONOutput returns true if --output json was specified +func isJSONOutput(cmd *cobra.Command) bool { + output, _ := cmd.Flags().GetString("output") + return strings.ToLower(output) == "json" +} + +// parseSinceDuration parses a duration string (e.g. "1h", "24h", "7d") into a time.Time +func parseSinceDuration(s string) (time.Time, error) { + if s == "" { + return time.Time{}, fmt.Errorf("duration cannot be empty") + } + + // Try standard Go duration (e.g., "1h", "30m", "2h30m") + d, err := time.ParseDuration(s) + if err == nil { + if d <= 0 { + return time.Time{}, fmt.Errorf("duration must be positive (e.g. 1h, 24h)") + } + return time.Now().Add(-d), nil + } + + // Try days format (e.g., "7d", "30d") + if strings.HasSuffix(s, "d") { + daysStr := strings.TrimSuffix(s, "d") + days, err := strconv.Atoi(daysStr) + if err == nil && days > 0 { + return time.Now().Add(-time.Duration(days) * 24 * time.Hour), nil + } + } + + return time.Time{}, fmt.Errorf("invalid duration %q (use e.g. 1h, 24h, 7d)", s) +} + +// parseStatuses parses a list of status strings into V1TaskStatus values +func parseStatuses(strs []string) ([]rest.V1TaskStatus, error) { + var statuses []rest.V1TaskStatus + for _, s := range strs { + switch strings.ToUpper(s) { + case "QUEUED": + statuses = append(statuses, rest.V1TaskStatusQUEUED) + case "RUNNING": + statuses = append(statuses, rest.V1TaskStatusRUNNING) + case "COMPLETED": + statuses = append(statuses, rest.V1TaskStatusCOMPLETED) + case "FAILED": + statuses = append(statuses, rest.V1TaskStatusFAILED) + case "CANCELLED": + statuses = append(statuses, rest.V1TaskStatusCANCELLED) + default: + return nil, fmt.Errorf("invalid status %q (valid: QUEUED,RUNNING,COMPLETED,FAILED,CANCELLED)", s) + } + } + return statuses, nil +} + +// resolveWorkflowID resolves a workflow name or UUID string to an openapi_types.UUID +func resolveWorkflowID(ctx context.Context, hatchetClient client.Client, nameOrID string) (openapi_types.UUID, error) { //nolint:staticcheck + // Try as UUID first + parsed, err := uuid.Parse(nameOrID) + if err == nil { + return parsed, nil + } + + // Search by name + tenantUUID, err := uuid.Parse(hatchetClient.TenantId()) + if err != nil { + return openapi_types.UUID{}, fmt.Errorf("invalid tenant ID: %w", err) + } + + resp, err := hatchetClient.API().WorkflowListWithResponse(ctx, tenantUUID, &rest.WorkflowListParams{ + Name: &nameOrID, + }) + if err != nil { + return openapi_types.UUID{}, fmt.Errorf("failed to fetch workflows: %w", err) + } + + if resp.JSON200 != nil && resp.JSON200.Rows != nil { + for _, wf := range *resp.JSON200.Rows { + if wf.Name == nameOrID { + wfUUID, err := uuid.Parse(wf.Metadata.Id) + if err == nil { + return wfUUID, nil + } + } + } + } + + return openapi_types.UUID{}, fmt.Errorf("workflow %q not found", nameOrID) +} + +// resolveWorkflowName returns the workflow name for a given name-or-UUID string. +// The cron/scheduled create endpoints use the workflow name as a path parameter +// (not the UUID), so this is needed when the user provides a UUID. +func resolveWorkflowName(ctx context.Context, hatchetClient client.Client, nameOrID string) (string, error) { //nolint:staticcheck + // If it's not a UUID, assume it's already a name + parsed, err := uuid.Parse(nameOrID) + if err != nil { + return nameOrID, nil + } + + // It's a UUID — look up the workflow to get its name + resp, err := hatchetClient.API().WorkflowGetWithResponse(ctx, parsed) + if err != nil { + return "", fmt.Errorf("failed to fetch workflow %q: %w", nameOrID, err) + } + if resp.JSON200 == nil { + return "", fmt.Errorf("workflow %q not found (status %d)", nameOrID, resp.StatusCode()) + } + + return resp.JSON200.Name, nil +} + +// promptSelectWorkflow fetches the list of workflows and shows an interactive selector. +// Returns the selected workflow name, or "" if no workflows are found or the form is cancelled. +func promptSelectWorkflow(ctx context.Context, hatchetClient client.Client) string { //nolint:staticcheck + tenantUUID := clientTenantUUID(hatchetClient) + limit := 200 + offset := 0 + resp, err := hatchetClient.API().WorkflowListWithResponse(ctx, tenantUUID, &rest.WorkflowListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil || resp.JSON200 == nil || resp.JSON200.Rows == nil || len(*resp.JSON200.Rows) == 0 { + return "" + } + + var options []huh.Option[string] + for _, wf := range *resp.JSON200.Rows { + options = append(options, huh.NewOption(wf.Name, wf.Name)) + } + + height := len(options) + if height > 10 { + height = 10 + } + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a workflow"). + Options(options...). + Height(height). + Value(&selected), + ), + ).WithTheme(styles.HatchetTheme()) + + if err := form.Run(); err != nil { + return "" + } + return selected +} + +// parseDurationFuture parses a duration string (e.g. "1h", "30m", "7d") for use with future times. +// Returns the duration to add to time.Now() to get the trigger time. +func parseDurationFuture(s string) (time.Duration, error) { + // Try standard Go duration + d, err := time.ParseDuration(s) + if err == nil { + if d <= 0 { + return 0, fmt.Errorf("duration must be positive (e.g. 1h, 30m)") + } + return d, nil + } + // Try days (e.g. "7d") + if strings.HasSuffix(s, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(s, "d")) + if err == nil && days > 0 { + return time.Duration(days) * 24 * time.Hour, nil + } + } + return 0, fmt.Errorf("invalid duration %q (use e.g. 5m, 2h, 7d)", s) +} + +// buildFilterAndParams builds a V1TaskFilter and V1WorkflowRunListParams from command flags +func buildFilterAndParams(ctx context.Context, cmd *cobra.Command, hatchetClient client.Client) (*rest.V1TaskFilter, *rest.V1WorkflowRunListParams, error) { //nolint:staticcheck + sinceStr, _ := cmd.Flags().GetString("since") + sinceTime, err := parseSinceDuration(sinceStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid --since value: %w", err) + } + + untilStr, _ := cmd.Flags().GetString("until") + var untilPtr *time.Time + if untilStr != "" { + var until time.Time + until, err = parseSinceDuration(untilStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid --until value: %w", err) + } + untilPtr = &until + } + + workflowStr, _ := cmd.Flags().GetString("workflow") + var workflowUUIDs *[]openapi_types.UUID + if workflowStr != "" { + var wfUUID openapi_types.UUID + wfUUID, err = resolveWorkflowID(ctx, hatchetClient, workflowStr) + if err != nil { + return nil, nil, fmt.Errorf("could not resolve workflow: %w", err) + } + workflowUUIDs = &[]openapi_types.UUID{wfUUID} + } + + statusStrs, _ := cmd.Flags().GetStringSlice("status") + statuses, err := parseStatuses(statusStrs) + if err != nil { + return nil, nil, err + } + var statusesPtr *[]rest.V1TaskStatus + if len(statuses) > 0 { + statusesPtr = &statuses + } + + filter := &rest.V1TaskFilter{ + Since: sinceTime, + Until: untilPtr, + Statuses: statusesPtr, + WorkflowIds: workflowUUIDs, + } + + listParams := &rest.V1WorkflowRunListParams{ + Since: sinceTime, + Until: untilPtr, + Statuses: statusesPtr, + } + if workflowUUIDs != nil { + listParams.WorkflowIds = workflowUUIDs + } + + return filter, listParams, nil +} + +// countMatchingRuns estimates the total number of runs matching the given params. +// It uses limit=1 and reads NumPages as a proxy for total count. Note: this is an +// approximation — the actual number affected by cancel/replay may differ slightly if +// run statuses change between this count call and the subsequent operation. +func countMatchingRuns(ctx context.Context, hatchetClient client.Client, tenantUUID openapi_types.UUID, params *rest.V1WorkflowRunListParams) int64 { //nolint:staticcheck + limit := int64(1) + countParams := *params + countParams.Limit = &limit + + resp, err := hatchetClient.API().V1WorkflowRunListWithResponse(ctx, tenantUUID, &countParams) + if err != nil || resp.JSON200 == nil { + return 0 + } + + if resp.JSON200.Pagination.NumPages != nil { + return *resp.JSON200.Pagination.NumPages + } + return 0 +} + +// confirmAction prompts the user to confirm an action, returns true if confirmed +func confirmAction(message string) bool { + fmt.Printf("%s [y/N]: ", message) + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + return input == "y" || input == "yes" +} + +// printJSON marshals and prints v as indented JSON to stdout +func printJSON(v any) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + cli.Logger.Fatalf("failed to marshal JSON: %v", err) + } + fmt.Println(string(data)) +} + +// shortID returns the first 8 characters of a UUID string followed by "..." +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + "..." + } + return id +} diff --git a/cmd/hatchet-cli/cli/runs_e2e_test.go b/cmd/hatchet-cli/cli/runs_e2e_test.go new file mode 100644 index 0000000000..b837b155d1 --- /dev/null +++ b/cmd/hatchet-cli/cli/runs_e2e_test.go @@ -0,0 +1,67 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestRunsListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("runs", "list") + + var result struct { + Rows []map[string]interface{} `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal runs list output: %v\nOutput: %s", err, out) + } + + // rows may be empty on a fresh server, but the field must be present + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } +} + +func TestRunsGetJSON(t *testing.T) { + runID := os.Getenv("HATCHET_TEST_RUN_ID") + if runID == "" { + t.Skip("HATCHET_TEST_RUN_ID not set; skipping runs get test") + } + + h := testharness.New(t) + out := h.RunJSON("runs", "get", runID) + + var result struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal runs get output: %v\nOutput: %s", err, out) + } + if result.Metadata.ID != runID { + t.Errorf("expected metadata.id = %q, got %q", runID, result.Metadata.ID) + } +} + +func TestRunsTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("runs", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Runs", "runs") { + t.Errorf("expected TUI to show 'Runs' header; got:\n%s", content) + } +} diff --git a/cmd/hatchet-cli/cli/scheduled.go b/cmd/hatchet-cli/cli/scheduled.go new file mode 100644 index 0000000000..b3fee77b1a --- /dev/null +++ b/cmd/hatchet-cli/cli/scheduled.go @@ -0,0 +1,329 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var scheduledCmd = &cobra.Command{ + Use: "scheduled", + Aliases: []string{"schedule", "scheduled-runs", "schedules"}, + Short: "Manage scheduled runs", + Long: `Commands for listing, inspecting, creating, and deleting scheduled workflow runs.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, +} + +var scheduledListCmd = &cobra.Command{ + Use: "list", + Short: "List scheduled runs", + Long: `List scheduled runs. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet scheduled list --profile local + + # JSON output + hatchet scheduled list -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeScheduledRuns + tuiM.currentView = tui.NewScheduledRunsView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + limit, _ := cmd.Flags().GetInt64("limit") + offset, _ := cmd.Flags().GetInt64("offset") + + params := &rest.WorkflowScheduledListParams{ + Limit: &limit, + Offset: &offset, + } + + resp, err := hatchetClient.API().WorkflowScheduledListWithResponse(ctx, tenantUUID, params) + if err != nil { + cli.Logger.Fatalf("failed to list scheduled runs: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var scheduledGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get scheduled run details", + Long: `Get details about a scheduled run. Outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + scheduledID := args[0] + _, hatchetClient := clientFromCmd(cmd) + + scheduledUUID, err := uuid.Parse(scheduledID) + if err != nil { + cli.Logger.Fatalf("invalid scheduled run ID %q: %v", scheduledID, err) + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + resp, err := hatchetClient.API().WorkflowScheduledGetWithResponse(ctx, tenantUUID, scheduledUUID) + if err != nil { + cli.Logger.Fatalf("failed to get scheduled run: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("scheduled run not found (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var scheduledCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a scheduled run", + Long: `Create a new scheduled workflow run. In --output json mode, all flags are required. Otherwise, launches an interactive form.`, + Example: ` # Interactive mode + hatchet scheduled create --profile local + + # JSON mode (all flags required) + hatchet scheduled create --workflow my-workflow --trigger-at 2026-01-01T12:00:00Z --input '{}' -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + _, hatchetClient := clientFromCmd(cmd) + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + workflowStr, _ := cmd.Flags().GetString("workflow") + triggerAtStr, _ := cmd.Flags().GetString("trigger-at") + inStr, _ := cmd.Flags().GetString("in") + inputStr, _ := cmd.Flags().GetString("input") + inputFile, _ := cmd.Flags().GetString("input-file") + + if !isJSON { + // Interactive mode: show workflow selector then remaining fields + if workflowStr == "" { + workflowStr = promptSelectWorkflow(ctx, hatchetClient) + if workflowStr == "" { + cli.Logger.Fatal("no workflow selected") + } + } + + // Prompt for trigger time and input if not already provided via flags + if triggerAtStr == "" && inStr == "" { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Trigger in (e.g. 5m, 2h, 1d) or at RFC3339 time"). + Value(&triggerAtStr). + Placeholder("1h"), + ), + huh.NewGroup( + huh.NewInput(). + Title("Input JSON (optional)"). + Value(&inputStr). + Placeholder("{}"), + ), + ).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil { + cli.Logger.Fatalf("form cancelled: %v", formErr) + } + } + } + + if workflowStr == "" { + cli.Logger.Fatal("--workflow is required") + } + + // Resolve trigger time: --in takes priority over --trigger-at + var triggerAt time.Time + if inStr != "" { + d, err := parseDurationFuture(inStr) + if err != nil { + cli.Logger.Fatalf("invalid --in value: %v", err) + } + triggerAt = time.Now().UTC().Add(d) + } else if triggerAtStr != "" { + // Accept RFC3339 or a Go duration (e.g. "1h") + var parseErr error + triggerAt, parseErr = time.Parse(time.RFC3339, triggerAtStr) + if parseErr != nil { + d, dErr := parseDurationFuture(triggerAtStr) + if dErr != nil { + cli.Logger.Fatalf("invalid --trigger-at value (use RFC3339 or a duration like 1h): %v", parseErr) + } + triggerAt = time.Now().UTC().Add(d) + } + } else { + cli.Logger.Fatal("--trigger-at or --in is required") + } + + if !triggerAt.After(time.Now()) { + cli.Logger.Fatalf("trigger time %s is in the past — use a future time or a duration like --in 1h", triggerAt.Format(time.RFC3339)) + } + + // Build input map + inputData := map[string]interface{}{} + if inputFile != "" { + data, readErr := os.ReadFile(inputFile) + if readErr != nil { + cli.Logger.Fatalf("failed to read --input-file: %v", readErr) + } + if readErr = json.Unmarshal(data, &inputData); readErr != nil { + cli.Logger.Fatalf("failed to parse --input-file as JSON: %v", readErr) + } + } else if inputStr != "" { + if parseErr := json.Unmarshal([]byte(inputStr), &inputData); parseErr != nil { + cli.Logger.Fatalf("failed to parse --input as JSON: %v", parseErr) + } + } + + // Resolve workflow name (the create endpoint uses name as path param, not UUID) + workflowName, resolveErr := resolveWorkflowName(ctx, hatchetClient, workflowStr) + if resolveErr != nil { + cli.Logger.Fatalf("could not resolve workflow: %v", resolveErr) + } + + resp, err := hatchetClient.API().ScheduledWorkflowRunCreateWithResponse(ctx, tenantUUID, workflowName, rest.ScheduledWorkflowRunCreateJSONRequestBody{ + TriggerAt: triggerAt, + Input: inputData, + AdditionalMetadata: map[string]interface{}{}, + }) + if err != nil { + cli.Logger.Fatalf("failed to create scheduled run: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d): %s", resp.StatusCode(), string(resp.Body)) + } + + if isJSON { + printJSON(resp.JSON200) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf( + "Created scheduled run: %s\n Workflow: %s\n Trigger at: %s", + resp.JSON200.Metadata.Id, + resp.JSON200.WorkflowName, + resp.JSON200.TriggerAt.Local().Format("2006-01-02 15:04:05 MST"), + ))) + } + }, +} + +var scheduledDeleteCmd = &cobra.Command{ + Use: "delete [scheduled-run-id]", + Short: "Delete a scheduled run", + Long: `Delete a scheduled run by ID. Omit the ID to pick from a list interactively. Use --yes to skip confirmation.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + _, hatchetClient := clientFromCmd(cmd) + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + + var scheduledID string + if len(args) == 1 { + scheduledID = args[0] + } else if !isJSON { + // No ID provided — show an interactive selector + limit := int64(100) + listResp, listErr := hatchetClient.API().WorkflowScheduledListWithResponse(ctx, tenantUUID, &rest.WorkflowScheduledListParams{ + Limit: &limit, + }) + if listErr != nil { + cli.Logger.Fatalf("failed to list scheduled runs: %v", listErr) + } + if listResp.JSON200 == nil || listResp.JSON200.Rows == nil || len(*listResp.JSON200.Rows) == 0 { + cli.Logger.Fatal("no scheduled runs found") + } + + var options []huh.Option[string] + for _, s := range *listResp.JSON200.Rows { + label := fmt.Sprintf("%s → %s", s.WorkflowName, s.TriggerAt.Format("2006-01-02 15:04:05")) + options = append(options, huh.NewOption(label, s.Metadata.Id)) + } + + height := len(options) + if height > 10 { + height = 10 + } + form := huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Select a scheduled run to delete"). + Options(options...). + Height(height). + Value(&scheduledID), + )).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil { + cli.Logger.Fatalf("selection cancelled: %v", formErr) + } + } else { + cli.Logger.Fatal("scheduled run ID is required in JSON mode") + } + + scheduledUUID, err := uuid.Parse(scheduledID) + if err != nil { + cli.Logger.Fatalf("invalid scheduled run ID %q: %v", scheduledID, err) + } + + if !isJSON && !yes { + if !confirmAction(fmt.Sprintf("Delete scheduled run '%s'?", shortID(scheduledID))) { + fmt.Println("Aborted.") + return + } + } + + resp, err := hatchetClient.API().WorkflowScheduledDeleteWithResponse(ctx, tenantUUID, scheduledUUID) + if err != nil { + cli.Logger.Fatalf("failed to delete scheduled run: %v", err) + } + if resp.StatusCode() >= 400 { + cli.Logger.Fatalf("failed to delete scheduled run (status %d)", resp.StatusCode()) + } + + if isJSON { + printJSON(map[string]interface{}{"deleted": true, "id": scheduledID}) + } else { + fmt.Println(styles.SuccessMessage(fmt.Sprintf("Deleted scheduled run: %s", scheduledID))) + } + }, +} + +func init() { + rootCmd.AddCommand(scheduledCmd) + scheduledCmd.AddCommand(scheduledListCmd, scheduledGetCmd, scheduledCreateCmd, scheduledDeleteCmd) + + scheduledCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + scheduledCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + + scheduledListCmd.Flags().Int64("limit", 50, "Number of results to return") + scheduledListCmd.Flags().Int64("offset", 0, "Offset for pagination") + + scheduledCreateCmd.Flags().StringP("workflow", "w", "", "Workflow name or ID") + scheduledCreateCmd.Flags().StringP("trigger-at", "t", "", "Trigger time in RFC3339 format or duration (e.g. 2026-01-01T12:00:00Z or 1h)") + scheduledCreateCmd.Flags().String("in", "", "Trigger after this duration from now (e.g. 5m, 2h, 1d)") + scheduledCreateCmd.Flags().StringP("input", "i", "", "Input JSON string") + scheduledCreateCmd.Flags().String("input-file", "", "Path to a JSON file for input") + + scheduledDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} diff --git a/cmd/hatchet-cli/cli/scheduled_e2e_test.go b/cmd/hatchet-cli/cli/scheduled_e2e_test.go new file mode 100644 index 0000000000..e7641660f5 --- /dev/null +++ b/cmd/hatchet-cli/cli/scheduled_e2e_test.go @@ -0,0 +1,119 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestScheduledListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("scheduled", "list") + + var result struct { + Rows []map[string]interface{} `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal scheduled list output: %v\nOutput: %s", err, out) + } + + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } +} + +func TestScheduledCreateDeleteJSON(t *testing.T) { + workflowID := os.Getenv("HATCHET_TEST_WORKFLOW_ID") + if workflowID == "" { + t.Skip("HATCHET_TEST_WORKFLOW_ID not set; skipping scheduled create/delete test") + } + + // Use a trigger time 1 hour in the future + triggerAt := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) + + h := testharness.New(t) + + // Create a scheduled run + createOut := h.RunJSON("scheduled", "create", + "--workflow", workflowID, + "--trigger-at", triggerAt, + "--input", "{}", + ) + + var created struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(createOut, &created); err != nil { + t.Fatalf("failed to unmarshal scheduled create output: %v\nOutput: %s", err, createOut) + } + if created.Metadata.ID == "" { + t.Fatal("expected metadata.id in scheduled create response") + } + + scheduledID := created.Metadata.ID + t.Logf("Created scheduled run: %s", scheduledID) + + // Delete the scheduled run + deleteOut := h.RunJSON("scheduled", "delete", "--yes", scheduledID) + + var deleted struct { + Deleted bool `json:"deleted"` + ID string `json:"id"` + } + if err := json.Unmarshal(deleteOut, &deleted); err != nil { + t.Fatalf("failed to unmarshal scheduled delete output: %v\nOutput: %s", err, deleteOut) + } + if !deleted.Deleted { + t.Errorf("expected deleted=true, got: %s", deleteOut) + } + if deleted.ID != scheduledID { + t.Errorf("expected deleted id %q, got %q", scheduledID, deleted.ID) + } + + t.Logf("Deleted scheduled run: %s", scheduledID) +} + +func TestScheduledGetJSON(t *testing.T) { + scheduledID := os.Getenv("HATCHET_TEST_SCHEDULED_ID") + if scheduledID == "" { + t.Skip("HATCHET_TEST_SCHEDULED_ID not set; skipping scheduled get test") + } + + h := testharness.New(t) + out := h.RunJSON("scheduled", "get", scheduledID) + + var result struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal scheduled get output: %v\nOutput: %s", err, out) + } + if result.Metadata.ID != scheduledID { + t.Errorf("expected metadata.id = %q, got %q", scheduledID, result.Metadata.ID) + } +} + +func TestScheduledTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("scheduled", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Scheduled Runs", "Scheduled", "Trigger") { + t.Errorf("expected TUI to show 'Scheduled Runs' header; got:\n%s", content) + } +} diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/SKILL.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/SKILL.md new file mode 100644 index 0000000000..890eabef7d --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/SKILL.md @@ -0,0 +1,32 @@ +--- +name: hatchet-cli +description: Hatchet CLI skills for managing workflows, workers, and runs. Use this skill when triggering workflows, starting workers, debugging runs, replaying failed runs, or setting up the Hatchet CLI and profiles. +license: MIT +metadata: + author: Hatchet + version: "1.0.0" + organization: Hatchet + date: February 2026 + abstract: Skills for using the Hatchet CLI to manage workflows, workers, and runs. Includes instructions for triggering workflows, starting workers, debugging runs, replaying runs, and setting up the CLI and profiles. Each skill references detailed documentation with step-by-step instructions and best practices. +--- + +# Hatchet CLI Agent Skills + +This skill package teaches AI agents how to use the Hatchet CLI to manage workflows, workers, and runs. + +## When to use these skills + +Read the relevant reference document before performing any Hatchet CLI task: + +- **Setting up the CLI or creating a profile** → `references/setup-cli.md` +- **Starting a worker** → `references/start-worker.md` +- **Triggering a workflow and waiting for results** → `references/trigger-and-watch.md` +- **Debugging a failed or stuck run** → `references/debug-run.md` +- **Replaying a run with the same or new input** → `references/replay-run.md` + +## Key conventions + +- Always specify a profile with `-p HATCHET_PROFILE` unless a default profile is set. +- Use `-o json` for machine-readable output when parsing responses. +- Write workflow input to a temp file (e.g. `/tmp/hatchet-input-$(date +%s)-$$.json`) to avoid collisions. +- Clean up temp files after use. diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/agents-entry.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/agents-entry.md new file mode 100644 index 0000000000..4cd9be501b --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/agents-entry.md @@ -0,0 +1,14 @@ + + +## Hatchet Agent Skills + +Hatchet agent skills are installed in `{SKILL_DIR}/`. When working with this project's Hatchet workflows, read the relevant reference: + +- **Setup CLI**: `{SKILL_DIR}/references/setup-cli.md` +- **Start worker**: `{SKILL_DIR}/references/start-worker.md` +- **Trigger & watch**: `{SKILL_DIR}/references/trigger-and-watch.md` +- **Debug a run**: `{SKILL_DIR}/references/debug-run.md` +- **Replay a run**: `{SKILL_DIR}/references/replay-run.md` + +Full skill: `{SKILL_DIR}/SKILL.md` + diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/_sections.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/_sections.md new file mode 100644 index 0000000000..10c06b9487 --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/_sections.md @@ -0,0 +1,9 @@ +# Hatchet CLI Reference Sections + +| Section | File | When to read | +|---------|------|--------------| +| Setup CLI | `setup-cli.md` | Installing the CLI, creating or listing profiles | +| Start worker | `start-worker.md` | Starting a dev worker for local development | +| Trigger & watch | `trigger-and-watch.md` | Triggering a workflow and polling for completion | +| Debug a run | `debug-run.md` | Diagnosing a failed, stuck, or unexpected run | +| Replay a run | `replay-run.md` | Re-running a previous workflow with same or new input | diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/debug-run.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/debug-run.md new file mode 100644 index 0000000000..fbaac16703 --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/debug-run.md @@ -0,0 +1,80 @@ +# Debug a Hatchet Run + +These are instructions for an AI agent to diagnose why a Hatchet run failed, is stuck, or behaved unexpectedly. Follow each step in order. + +## Step 1: Get Run State + +```bash +hatchet runs get RUN_ID -o json -p HATCHET_PROFILE +``` + +Examine the JSON response: + +- `.run.status` -- the overall run status (`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`) +- `.run.displayName` -- the workflow name +- `.run.input` -- the input that was provided when the workflow was triggered +- `.tasks[]` -- each task in the run, with its own `status`, `displayName`, and `output` or `errorMessage` +- `.tasks[].startedAt` and `.tasks[].finishedAt` -- timing information + +## Step 2: Get Event Log + +```bash +hatchet runs events RUN_ID -o json -p HATCHET_PROFILE +``` + +The event log shows the full lifecycle of the run. Each event has: + +- `eventType` -- what happened (e.g. `QUEUED`, `STARTED`, `FINISHED`, `FAILED`, `CANCELLED`) +- `message` -- human-readable description +- `taskDisplayName` -- which task the event belongs to +- `timestamp` -- when it happened + +Read events in chronological order to understand the sequence of what happened. + +## Step 3: Get Logs + +```bash +hatchet runs logs RUN_ID -p HATCHET_PROFILE +``` + +This prints the application-level log output from the task code (e.g. print statements, logger calls). For multi-task (DAG) runs, logs from all tasks are merged and sorted by timestamp with task name prefixes. + +## Diagnostic Cheat Sheet + +### Task stuck in QUEUED (no STARTED event) + +The task was never picked up by a worker. Likely causes: + +- **No worker is running.** Start one with `hatchet worker dev -p HATCHET_PROFILE`. +- **Worker does not register this task type.** The task name in the workflow definition must match what the worker code registers. Check the worker startup logs. +- **Worker is connected to a different tenant/profile.** Verify the worker and the trigger use the same `-p` profile. + +### Task FAILED + +Check the logs output from Step 3 for a stack trace or error message. Common causes: + +- Unhandled exception in the task code. +- Timeout exceeded. +- A dependency (database, API, etc.) was unreachable. + +The events from Step 2 will show a `FAILED` event with an error message. + +### Task CANCELLED + +Check the events for a `CANCELLED` event. The message field indicates the cancellation source: + +- Manual cancellation via the dashboard or CLI. +- Parent workflow cancellation (for DAG workflows). +- Timeout-based cancellation. + +### Run COMPLETED but output is wrong + +If the run completed but produced unexpected results: + +1. Check `.tasks[].output` in the run state (Step 1) to see what each task returned. +2. Check the logs (Step 3) to trace the execution flow. +3. Check `.run.input` to verify the correct input was provided. + +### Slow execution + +Compare `startedAt` and `finishedAt` timestamps for each task in Step 1 to identify which task is the bottleneck. Check if there is a long gap between the run being triggered and the first task starting (indicates queuing delay, possibly due to worker capacity). diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/replay-run.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/replay-run.md new file mode 100644 index 0000000000..eb56ea4afe --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/replay-run.md @@ -0,0 +1,78 @@ +# Replay a Hatchet Run + +These are instructions for an AI agent to replay a previously executed Hatchet run, optionally with modified input. Follow each step in order. + +## Step 1: Inspect the Original Run + +First, understand what happened in the original run: + +```bash +hatchet runs get RUN_ID -o json -p HATCHET_PROFILE +``` + +From the response, note: + +- `.run.displayName` -- the workflow name (needed if re-triggering with new input) +- `.run.input` -- the original input JSON +- `.run.status` -- why you might be replaying (e.g. `FAILED`) +- `.tasks[].status` and `.tasks[].errorMessage` -- which specific tasks failed and why + +## Step 2a: Replay with the Same Input + +If you want to re-run the exact same workflow with the same input (e.g. after fixing a bug in the task code): + +```bash +hatchet runs replay RUN_ID -o json -p HATCHET_PROFILE +``` + +This creates a new run of the same workflow with the same input. The response includes the new run IDs in `.ids[]`. + +## Step 2b: Replay with New Input + +If you need to change the input (e.g. fixing bad input data), you must trigger a new run instead of using replay: + +Write the new input JSON to a uniquely-named temp file: + +```bash +HATCHET_INPUT_FILE="/tmp/hatchet-input-$(date +%s)-$$.json" +cat > "$HATCHET_INPUT_FILE" << 'ENDJSON' +NEW_INPUT_JSON +ENDJSON +``` + +Then trigger a fresh run using the workflow name from Step 1: + +```bash +RUN_ID=$(hatchet trigger manual -w WORKFLOW_NAME -j "$HATCHET_INPUT_FILE" -p HATCHET_PROFILE -o json | jq -r '.runId') +``` + +Clean up the temp file: + +```bash +rm -f "$HATCHET_INPUT_FILE" +``` + +## Step 3: Watch the New Run + +After either Step 2a or 2b, you have a new run ID. Poll for completion: + +```bash +hatchet runs get -o json -p HATCHET_PROFILE +``` + +Check `.run.status` and `.tasks[].status` every 5 seconds until all reach a terminal state (`COMPLETED`, `FAILED`, `CANCELLED`). + +If the new run also fails, use the debug instructions: + +1. `hatchet runs logs -p HATCHET_PROFILE` for application logs +2. `hatchet runs events -o json -p HATCHET_PROFILE` for lifecycle events + +## Common Replay Workflow + +The typical flow when an agent is iterating on task code: + +1. Trigger a run and it fails +2. Read the logs/events to understand why +3. Fix the code (the worker auto-reloads if `reload: true` in `hatchet.yaml`) +4. Replay the run with `hatchet runs replay RUN_ID -o json -p HATCHET_PROFILE` +5. If it fails again, repeat from step 2 diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/setup-cli.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/setup-cli.md new file mode 100644 index 0000000000..7409dee62e --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/setup-cli.md @@ -0,0 +1,75 @@ +# Install and Set Up the Hatchet CLI + +These are instructions for an AI agent to install the Hatchet CLI and configure a profile. Follow each step in order. + +## Step 1: Check if Already Installed + +```bash +hatchet --version +``` + +If this prints a version number, the CLI is already installed. Skip to Step 3 (profile setup). + +If the command is not found, proceed to Step 2. + +## Step 2: Install the CLI + +On macOS, Linux, or WSL: + +```bash +curl -fsSL https://install.hatchet.run/install.sh | bash +``` + +Alternatively, on macOS via Homebrew: + +```bash +brew install hatchet-dev/hatchet/hatchet --cask +``` + +After installation, verify it worked: + +```bash +hatchet --version +``` + +## Step 3: Check for Existing Profiles + +```bash +hatchet profile list +``` + +If a profile already exists that connects to the correct Hatchet instance, note its name and use it as the `-p` flag in all subsequent commands. You are done. + +If no profiles exist or the correct one is missing, proceed to Step 4. + +## Step 4: Create a Profile + +You need a Hatchet API token. Ask the user for one if you do not have it. Then create a profile: + +```bash +hatchet profile add --name HATCHET_PROFILE --token +``` + +Replace `HATCHET_PROFILE` with a descriptive name (e.g. `local`, `staging`, `production`) and `` with the actual token. + +To set it as the default profile (so `-p` is optional in future commands): + +```bash +hatchet profile set-default --name HATCHET_PROFILE +``` + +## Step 5: Verify Connectivity + +Test that the profile works by listing workflows: + +```bash +hatchet runs list -o json -p HATCHET_PROFILE --since 1h --limit 1 +``` + +If this returns a JSON response (even with an empty rows list), the profile is correctly configured and connected. + +## Troubleshooting + +- **"command not found"** after install: The CLI binary may not be on your PATH. Check `~/.local/bin/hatchet` or re-run the install script. +- **Authentication error**: The API token may be invalid or expired. Ask the user for a new token and run `hatchet profile update`. +- **Connection refused**: The Hatchet server may not be running. For local development, start it with `hatchet server start`. diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/start-worker.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/start-worker.md new file mode 100644 index 0000000000..cab68c79a5 --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/start-worker.md @@ -0,0 +1,58 @@ +# Start a Hatchet Worker in Dev Mode + +These are instructions for an AI agent to start a Hatchet worker using the CLI. Follow each step in order. + +## Prerequisites + +You need a `hatchet.yaml` file in the project root. If one does not exist, create it with the following structure: + +```yaml +dev: + runCmd: "python src/worker.py" + files: + - "**/*.py" + reload: true +``` + +Adjust `runCmd` to match the project's language and entry point: + +- Python: `poetry run python src/worker.py` or `python src/worker.py` +- TypeScript/Node: `npx ts-node src/worker.ts` or `npm run dev` +- Go: `go run ./cmd/worker` + +The `files` list contains glob patterns for file watching. The `reload: true` setting enables automatic worker restart when watched files change. + +## Start the Worker + +Run the following command in a **background terminal** (the worker is a long-running process that must stay alive): + +```bash +hatchet worker dev -p HATCHET_PROFILE +``` + +The worker will connect to Hatchet using the specified profile and begin listening for tasks. + +## Important Notes + +- The worker **must be running** before you trigger any workflows. If a workflow is triggered with no worker running, tasks will remain in QUEUED status indefinitely. +- When `reload: true` is set, the worker automatically restarts when any watched file changes. This means you can edit task code and the worker picks up changes without manual restart. +- To disable auto-reload, add `--no-reload`. +- To override the run command without editing `hatchet.yaml`, use `--run-cmd "your command here"`. +- If the worker fails to start, check that the profile exists (`hatchet profile list`) and that the Hatchet server is reachable. + +## Optional: Pre-commands + +You can add setup commands that run before the worker starts: + +```yaml +dev: + preCmds: + - "poetry install" + - "npm install" + runCmd: "poetry run python src/worker.py" + files: + - "**/*.py" + reload: true +``` + +These run once each time the worker starts (including on reload). diff --git a/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/trigger-and-watch.md b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/trigger-and-watch.md new file mode 100644 index 0000000000..9d071b66a2 --- /dev/null +++ b/cmd/hatchet-cli/cli/skill-assets/hatchet-cli/references/trigger-and-watch.md @@ -0,0 +1,75 @@ +# Trigger a Workflow and Watch for Completion + +These are instructions for an AI agent to trigger a Hatchet workflow and poll until it completes. Follow each step in order. + +## Prerequisites + +- A Hatchet worker must be running (`hatchet worker dev -p HATCHET_PROFILE`). If no worker is running, the task will stay QUEUED forever. +- You must know the workflow name and have the input JSON ready. + +## Step 1: Write Input to a Temp File + +Write the input JSON to a uniquely-named temp file to avoid collisions with other sessions: + +```bash +HATCHET_INPUT_FILE="/tmp/hatchet-input-$(date +%s)-$$.json" +cat > "$HATCHET_INPUT_FILE" << 'ENDJSON' +INPUT_JSON +ENDJSON +``` + +Replace `INPUT_JSON` with the actual JSON payload for the workflow. + +## Step 2: Trigger the Workflow + +```bash +RUN_ID=$(hatchet trigger manual -w WORKFLOW_NAME -j "$HATCHET_INPUT_FILE" -p HATCHET_PROFILE -o json | jq -r '.runId') +``` + +The `-o json` flag makes the command output `{"runId": "...", "workflow": "..."}` to stdout. The command above captures the run ID directly into `$RUN_ID` for the next steps. + +## Step 3: Poll for Completion + +Run the following command every 5 seconds until the run reaches a terminal state: + +```bash +hatchet runs get -o json -p HATCHET_PROFILE +``` + +Parse the JSON response and check the status: + +- Look at `.run.status` for the overall run status and `.tasks[].status` for individual task statuses. +- Terminal statuses are: `COMPLETED`, `FAILED`, `CANCELLED`. +- Non-terminal statuses are: `QUEUED`, `RUNNING`. Keep polling if you see these. + +## Step 4: Handle Failure + +If the run status is `FAILED`, gather diagnostic information: + +### Fetch logs + +```bash +hatchet runs logs -p HATCHET_PROFILE +``` + +This prints application-level log output (e.g. print statements, logger calls from your task code). Look for error messages, stack traces, or unexpected output. + +### Fetch events + +```bash +hatchet runs events -o json -p HATCHET_PROFILE +``` + +This returns the lifecycle event log showing how the task was dispatched, started, and failed. Look at the `eventType` and `message` fields to understand the failure sequence. + +## Step 5: Clean Up + +```bash +rm -f "$HATCHET_INPUT_FILE" +``` + +## Common Issues + +- **Task stays QUEUED**: The worker is not running, or the workflow/task name does not match what the worker registered. Start or restart the worker. +- **Task FAILED immediately**: Check the logs for a stack trace. The task code likely threw an exception. +- **Task CANCELLED**: Something cancelled the run externally. Check events to see the cancellation source. diff --git a/cmd/hatchet-cli/cli/skills.go b/cmd/hatchet-cli/cli/skills.go new file mode 100644 index 0000000000..5dfd117586 --- /dev/null +++ b/cmd/hatchet-cli/cli/skills.go @@ -0,0 +1,292 @@ +package cli + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" +) + +//go:embed all:skill-assets +var skillAssets embed.FS + +var ( + skillsInstallDir string + skillsInstallForce bool +) + +// skillsCmd represents the skills parent command +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage Hatchet agent skills for AI coding agents", + Long: `Hatchet agent skills are reference documents that teach AI coding agents +how to use the Hatchet CLI to manage workflows, workers, and runs. + +Install the skill package into your project to give agents step-by-step +instructions for common Hatchet operations.`, + Example: ` # Install skills interactively + hatchet skills install + + # Install to a custom directory + hatchet skills install --dir ./my-project`, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +// skillsInstallCmd represents the skills install subcommand +var skillsInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install the Hatchet CLI agent skill package into your project", + Long: `Install Hatchet CLI agent skills into your project. + +Creates the skill directory structure under {dir}/skills/hatchet-cli/ and +appends a reference section to the project AGENTS.md file.`, + Example: ` # Install to current directory (creates ./skills/hatchet-cli/) + hatchet skills install + + # Install to a custom base directory + hatchet skills install --dir ./my-project + + # Skip confirmation prompts + hatchet skills install --force`, + Run: func(cmd *cobra.Command, args []string) { + runSkillsInstall() + }, +} + +func init() { + rootCmd.AddCommand(skillsCmd) + skillsCmd.AddCommand(skillsInstallCmd) + + skillsInstallCmd.Flags().StringVarP(&skillsInstallDir, "dir", "d", ".", "Target base directory (skill installs under {dir}/skills/hatchet-cli/)") + skillsInstallCmd.Flags().BoolVarP(&skillsInstallForce, "force", "f", false, "Skip confirmation prompts") +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +func runSkillsInstall() { + // 1. Resolve paths + baseDir, err := filepath.Abs(skillsInstallDir) + if err != nil { + cli.Logger.Fatalf("could not resolve directory: %v", err) + } + + skillDir := filepath.Join(baseDir, "skills", "hatchet-cli") + agentsFile := filepath.Join(baseDir, "AGENTS.md") + + // 2. Print header and summary + fmt.Println(styles.Title("Hatchet Agent Skills Install")) + fmt.Println() + fmt.Println(styles.InfoMessage("The following will be created:")) + fmt.Println() + fmt.Printf(" %s\n", skillDir+"/SKILL.md") + fmt.Printf(" %s\n", skillDir+"/AGENTS.md") + fmt.Printf(" %s\n", skillDir+"/CLAUDE.md (symlink → AGENTS.md)") + fmt.Printf(" %s\n", skillDir+"/references/setup-cli.md") + fmt.Printf(" %s\n", skillDir+"/references/start-worker.md") + fmt.Printf(" %s\n", skillDir+"/references/trigger-and-watch.md") + fmt.Printf(" %s\n", skillDir+"/references/debug-run.md") + fmt.Printf(" %s\n", skillDir+"/references/replay-run.md") + fmt.Println() + fmt.Printf(" %s (appended)\n", agentsFile) + fmt.Println() + + // 3. Check if skill directory already exists + if _, statErr := os.Stat(skillDir); statErr == nil { + // Directory exists + if !skillsInstallForce { + fmt.Println(styles.InfoMessage("Skill directory already exists: " + skillDir)) + fmt.Println() + var overwrite bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Overwrite existing skill files?"). + Value(&overwrite), + ), + ).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil || !overwrite { + fmt.Println(styles.InfoMessage("Install cancelled.")) + return + } + fmt.Println() + } + } + + // 4. Check AGENTS.md for existing marker + appendToAgents := true + if data, readErr := os.ReadFile(agentsFile); readErr == nil { + if strings.Contains(string(data), "") { + fmt.Println(styles.InfoMessage("AGENTS.md already contains a hatchet-skills section.")) + fmt.Println() + if !skillsInstallForce { + var appendAgain bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Append another hatchet-skills section to AGENTS.md?"). + Value(&appendAgain), + ), + ).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil || !appendAgain { + appendToAgents = false + fmt.Println() + } else { + fmt.Println() + } + } else { + appendToAgents = false + } + } + } + + // 5. Confirm overall installation (unless --force) + if !skillsInstallForce { + var confirm bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Proceed with installation?"). + Value(&confirm), + ), + ).WithTheme(styles.HatchetTheme()) + if formErr := form.Run(); formErr != nil || !confirm { + fmt.Println(styles.InfoMessage("Install cancelled.")) + return + } + fmt.Println() + } + + // 6. Create skill directory structure + if mkdirErr := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); mkdirErr != nil { + cli.Logger.Fatalf("could not create skill directory: %v", mkdirErr) + } + + // Walk embedded skill-assets/hatchet-cli/ and write each file + srcRoot := "skill-assets/hatchet-cli" + err = fs.WalkDir(skillAssets, srcRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + + // Skip agents-entry.md — it's a template, not installed directly + if d.Name() == "agents-entry.md" { + return nil + } + + // Compute destination path + rel, relErr := filepath.Rel(srcRoot, path) + if relErr != nil { + return relErr + } + dest := filepath.Join(skillDir, rel) + + // Ensure parent directory exists + if mkdirErr := os.MkdirAll(filepath.Dir(dest), 0o755); mkdirErr != nil { + return mkdirErr + } + + fileData, readErr := skillAssets.ReadFile(path) + if readErr != nil { + return readErr + } + + return os.WriteFile(dest, fileData, 0o600) + }) + if err != nil { + cli.Logger.Fatalf("could not write skill files: %v", err) + } + + // Generate AGENTS.md for the skill (SKILL.md body without YAML frontmatter) + skillMDData, readErr := skillAssets.ReadFile(srcRoot + "/SKILL.md") + if readErr != nil { + cli.Logger.Fatalf("could not read SKILL.md: %v", readErr) + } + skillAgentsContent := stripFrontmatter(string(skillMDData)) + if writeErr := os.WriteFile(filepath.Join(skillDir, "AGENTS.md"), []byte(skillAgentsContent), 0o600); writeErr != nil { + cli.Logger.Fatalf("could not write skill AGENTS.md: %v", writeErr) + } + + // Create CLAUDE.md symlink pointing to AGENTS.md + claudeMDPath := filepath.Join(skillDir, "CLAUDE.md") + // Remove existing symlink/file if present (we already confirmed overwrite above) + _ = os.Remove(claudeMDPath) + if symlinkErr := os.Symlink("AGENTS.md", claudeMDPath); symlinkErr != nil { + fmt.Printf(" ⚠ Could not create CLAUDE.md symlink: %v\n", symlinkErr) + } + + fmt.Println(styles.SuccessMessage("Skill installed to " + skillDir)) + + // 7. Append to project AGENTS.md + if appendToAgents { + entryData, readErr := skillAssets.ReadFile(srcRoot + "/agents-entry.md") + if readErr != nil { + cli.Logger.Fatalf("could not read agents-entry.md template: %v", readErr) + } + + // Compute relative path from base dir to skill dir for use in the template + relSkillDir, relErr := filepath.Rel(baseDir, skillDir) + if relErr != nil { + relSkillDir = skillDir + } + + entry := strings.ReplaceAll(string(entryData), "{SKILL_DIR}", relSkillDir) + + if _, statErr := os.Stat(agentsFile); os.IsNotExist(statErr) { + // Create AGENTS.md with the entry + if writeErr := os.WriteFile(agentsFile, []byte(entry), 0o600); writeErr != nil { + cli.Logger.Fatalf("could not create AGENTS.md: %v", writeErr) + } + } else { + // Append to existing AGENTS.md + f, openErr := os.OpenFile(agentsFile, os.O_APPEND|os.O_WRONLY, 0o600) + if openErr != nil { + cli.Logger.Fatalf("could not open AGENTS.md for appending: %v", openErr) + } + defer f.Close() + if _, writeErr := f.WriteString(entry); writeErr != nil { + cli.Logger.Fatalf("could not append to AGENTS.md: %v", writeErr) + } + } + + fmt.Println(styles.SuccessMessage("AGENTS.md updated")) + } + + fmt.Println() + fmt.Println(styles.Section("Next steps")) + fmt.Println() + fmt.Println(" • Run " + styles.Code.Render("hatchet docs install") + " to add the Hatchet MCP server to your AI editor") + fmt.Println(" • Commit " + styles.Code.Render("skills/") + " and " + styles.Code.Render("AGENTS.md") + " to version control") + fmt.Println() +} + +// stripFrontmatter removes YAML frontmatter (content between leading --- delimiters) +// and returns the remaining body. +func stripFrontmatter(content string) string { + content = strings.TrimLeft(content, "\r\n") + if !strings.HasPrefix(content, "---") { + return content + } + // Find the closing --- + rest := content[3:] + _, body, found := strings.Cut(rest, "\n---") + if !found { + return content + } + body = strings.TrimLeft(body, "\r\n") + return body +} diff --git a/cmd/hatchet-cli/cli/testharness/harness.go b/cmd/hatchet-cli/cli/testharness/harness.go new file mode 100644 index 0000000000..65e6a8397b --- /dev/null +++ b/cmd/hatchet-cli/cli/testharness/harness.go @@ -0,0 +1,130 @@ +//go:build e2e_cli + +package testharness + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +// CLIHarness provides helpers for running CLI commands in tests. +// It builds the hatchet binary once per test run and caches the path. +type CLIHarness struct { + // BinaryPath is the path to the compiled hatchet CLI binary. + BinaryPath string + // Profile is the --profile flag value to pass to commands. + Profile string + T *testing.T +} + +var ( + binaryOnce sync.Once + binaryPath string + binaryErr error +) + +// New builds the CLI binary to a temp dir and returns a CLIHarness. +// The profile is read from the HATCHET_CLI_PROFILE environment variable. +// If the env var is not set, it defaults to "local". +func New(t *testing.T) *CLIHarness { + t.Helper() + + profile := os.Getenv("HATCHET_CLI_PROFILE") + if profile == "" { + profile = "local" + } + + // Build binary once per test process. + // Use os.MkdirTemp (not t.TempDir) so the directory outlives the first test + // that calls New — t.TempDir cleanup runs when that test finishes, which + // would delete the binary before subsequent tests can use it. + binaryOnce.Do(func() { + dir, mkErr := os.MkdirTemp("", "hatchet-cli-e2e-*") + if mkErr != nil { + binaryErr = fmt.Errorf("failed to create temp dir: %w", mkErr) + return + } + out := filepath.Join(dir, "hatchet") + + cmd := exec.Command("go", "build", "-o", out, "./cmd/hatchet-cli") + cmd.Dir = findModuleRoot(t) + cmd.Env = os.Environ() + + output, err := cmd.CombinedOutput() + if err != nil { + binaryErr = fmt.Errorf("failed to build hatchet CLI: %w\nOutput: %s", err, output) + return + } + + binaryPath = out + }) + + if binaryErr != nil { + t.Fatalf("CLIHarness: %v", binaryErr) + } + + return &CLIHarness{ + BinaryPath: binaryPath, + Profile: profile, + T: t, + } +} + +// RunJSON runs a CLI command with -o json appended, asserts exit 0, and returns stdout. +func (h *CLIHarness) RunJSON(args ...string) []byte { + h.T.Helper() + fullArgs := append(args, "-o", "json", "--profile", h.Profile) + return h.run(fullArgs...) +} + +// Run runs a CLI command without -o json and returns stdout. +func (h *CLIHarness) Run(args ...string) string { + h.T.Helper() + fullArgs := append(args, "--profile", h.Profile) + return string(h.run(fullArgs...)) +} + +// run executes the binary with the given args and returns stdout. +// It fatals the test if the command exits non-zero. +func (h *CLIHarness) run(args ...string) []byte { + h.T.Helper() + + cmd := exec.Command(h.BinaryPath, args...) + output, err := cmd.Output() + if err != nil { + var stderr string + if exitErr, ok := err.(*exec.ExitError); ok { + stderr = string(exitErr.Stderr) + } + h.T.Fatalf("hatchet %s failed: %v\nStderr: %s\nStdout: %s", + strings.Join(args, " "), err, stderr, output) + } + + return output +} + +// findModuleRoot walks up from the test's working directory to find go.mod +func findModuleRoot(t *testing.T) string { + t.Helper() + + dir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find go.mod - not in a Go module?") + } + dir = parent + } +} diff --git a/cmd/hatchet-cli/cli/testharness/tui.go b/cmd/hatchet-cli/cli/testharness/tui.go new file mode 100644 index 0000000000..e8af886203 --- /dev/null +++ b/cmd/hatchet-cli/cli/testharness/tui.go @@ -0,0 +1,106 @@ +//go:build e2e_cli + +package testharness + +import ( + "fmt" + "os/exec" + "strings" + "testing" + "time" +) + +// TUIHarness provides helpers for testing the TUI via tmux. +// It requires tmux to be installed on the test machine. +type TUIHarness struct { + harness *CLIHarness + sessionName string + paneID string + T *testing.T +} + +// NewTUI creates a new TUIHarness using the given CLIHarness. +func NewTUI(t *testing.T, h *CLIHarness) *TUIHarness { + t.Helper() + return &TUIHarness{ + harness: h, + sessionName: fmt.Sprintf("hatchet-tui-test-%d", time.Now().UnixNano()), + T: t, + } +} + +// shellEscape returns a single-quoted POSIX shell-safe version of s. +// Any single quotes within s are escaped by ending the current single-quoted +// string, inserting an escaped single quote, and reopening the single-quoted string. +func shellEscape(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// Start launches the TUI command in a new tmux session. +// args are passed to the hatchet binary (e.g., "tui", "--profile", "local"). +func (th *TUIHarness) Start(args ...string) { + th.T.Helper() + + fullArgs := append([]string{th.harness.BinaryPath}, args...) + fullArgs = append(fullArgs, "--profile", th.harness.Profile) + escaped := make([]string, len(fullArgs)) + for i, a := range fullArgs { + escaped[i] = shellEscape(a) + } + cmdStr := strings.Join(escaped, " ") + + // Create a new tmux session (detached) + cmd := exec.Command("tmux", "new-session", + "-d", + "-s", th.sessionName, + "-x", "220", + "-y", "50", + cmdStr, + ) + + if output, err := cmd.CombinedOutput(); err != nil { + th.T.Fatalf("failed to start tmux session: %v\nOutput: %s", err, output) + } + + th.paneID = th.sessionName + ":0" +} + +// CaptureAfter waits for the given duration and then captures the pane contents. +func (th *TUIHarness) CaptureAfter(d time.Duration) string { + th.T.Helper() + + time.Sleep(d) + + cmd := exec.Command("tmux", "capture-pane", + "-t", th.paneID, + "-p", + "-e", + ) + + output, err := cmd.Output() + if err != nil { + th.T.Fatalf("failed to capture tmux pane: %v", err) + } + + return string(output) +} + +// SendKey sends a key sequence to the tmux pane. +// key should be a tmux key name (e.g., "q", "Enter", "ctrl+c"). +func (th *TUIHarness) SendKey(key string) { + th.T.Helper() + + cmd := exec.Command("tmux", "send-keys", "-t", th.paneID, key, "Enter") + if output, err := cmd.CombinedOutput(); err != nil { + th.T.Fatalf("failed to send key %q: %v\nOutput: %s", key, err, output) + } +} + +// Stop kills the tmux session and cleans up. +func (th *TUIHarness) Stop() { + if th.sessionName == "" { + return + } + // Kill the tmux session, ignore errors (session may already be dead) + exec.Command("tmux", "kill-session", "-t", th.sessionName).Run() //nolint:errcheck +} diff --git a/cmd/hatchet-cli/cli/trigger.go b/cmd/hatchet-cli/cli/trigger.go index a13d495e48..9a52be62ff 100644 --- a/cmd/hatchet-cli/cli/trigger.go +++ b/cmd/hatchet-cli/cli/trigger.go @@ -51,6 +51,9 @@ var triggerCmd = &cobra.Command{ # Manually trigger a workflow non-interactively hatchet trigger manual --workflow my-workflow --json ./input.json + # Non-interactive with JSON output (prints {"runId": "...", "workflow": "..."}) + hatchet trigger manual --workflow my-workflow --json ./input.json -o json + # Run with a specific profile hatchet trigger bulk --profile local`, Run: func(cmd *cobra.Command, args []string) { @@ -73,6 +76,7 @@ func init() { triggerCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") triggerCmd.Flags().StringP("workflow", "w", "", "Workflow name for manual triggering (non-interactive mode)") triggerCmd.Flags().StringP("json", "j", "", "Path to JSON input file for manual triggering (non-interactive mode)") + triggerCmd.Flags().StringP("output", "o", "", "Output format: json (prints run ID as JSON, skips TUI prompt)") } // executeTrigger is the main entry point for trigger execution @@ -301,10 +305,12 @@ func runManualTrigger(cmd *cobra.Command, workflowFlag string, jsonFlag string, cli.Logger.Fatalf("could not create Hatchet client: %v", err) } + jsonOutput := isJSONOutput(cmd) + if interactive { - runManualInteractive(profile, hatchetClient) + runManualInteractive(profile, hatchetClient, jsonOutput) } else { - runManualNonInteractive(profile, hatchetClient, workflowFlag, jsonFlag) + runManualNonInteractive(profile, hatchetClient, workflowFlag, jsonFlag, jsonOutput) } } @@ -316,7 +322,7 @@ type WorkflowInfo struct { } // runManualInteractive runs manual workflow triggering in interactive mode -func runManualInteractive(profile *profileconfig.Profile, hatchetClient client.Client) { +func runManualInteractive(profile *profileconfig.Profile, hatchetClient client.Client, jsonOutput bool) { //nolint:staticcheck ctx := context.Background() // Get tenant UUID @@ -381,12 +387,20 @@ func runManualInteractive(profile *profileconfig.Profile, hatchetClient client.C cli.Logger.Fatalf("error triggering workflow: %v", err) } + if jsonOutput { + printJSON(struct { + RunID string `json:"runId"` + Workflow string `json:"workflow"` + }{RunID: runID, Workflow: selectedWorkflow.Name}) + return + } + // Display success message with TUI prompt (interactive mode only) displaySuccessMessage(runID, selectedWorkflow.Name, profile.Name, hatchetClient) } // runManualNonInteractive runs manual workflow triggering in non-interactive mode -func runManualNonInteractive(profile *profileconfig.Profile, hatchetClient client.Client, workflowName string, jsonPath string) { +func runManualNonInteractive(profile *profileconfig.Profile, hatchetClient client.Client, workflowName string, jsonPath string, jsonOutput bool) { //nolint:staticcheck ctx := context.Background() // Get tenant UUID @@ -443,12 +457,22 @@ func runManualNonInteractive(profile *profileconfig.Profile, hatchetClient clien // Trigger workflow selectedWorkflowName := selectedWorkflow.Name - fmt.Println(styles.InfoMessage(fmt.Sprintf("Triggering workflow: %s", selectedWorkflowName))) + if !jsonOutput { + fmt.Println(styles.InfoMessage(fmt.Sprintf("Triggering workflow: %s", selectedWorkflowName))) + } runID, err := triggerWorkflowWithClient(hatchetClient, selectedWorkflowName, jsonInput) if err != nil { cli.Logger.Fatalf("error triggering workflow: %v", err) } + if jsonOutput { + printJSON(struct { + RunID string `json:"runId"` + Workflow string `json:"workflow"` + }{RunID: runID, Workflow: selectedWorkflowName}) + return + } + // Display success message (no TUI prompt in non-interactive mode) fmt.Println() fmt.Println(styles.SuccessMessage("Workflow triggered successfully")) diff --git a/cmd/hatchet-cli/cli/tui.go b/cmd/hatchet-cli/cli/tui.go index 1e91057f94..9079e32e15 100644 --- a/cmd/hatchet-cli/cli/tui.go +++ b/cmd/hatchet-cli/cli/tui.go @@ -96,6 +96,10 @@ const ( ViewTypeRuns ViewType = iota ViewTypeWorkflows ViewTypeWorkers + ViewTypeRateLimits + ViewTypeScheduledRuns + ViewTypeCronJobs + ViewTypeWebhooks ) // viewOption represents a selectable view in the view selector @@ -110,6 +114,10 @@ var availableViews = []viewOption{ {Type: ViewTypeRuns, Name: "Runs", Description: "View task runs"}, {Type: ViewTypeWorkflows, Name: "Workflows", Description: "View workflows"}, {Type: ViewTypeWorkers, Name: "Workers", Description: "View workers"}, + {Type: ViewTypeRateLimits, Name: "Rate Limits", Description: "View rate limit usage"}, + {Type: ViewTypeScheduledRuns, Name: "Scheduled Runs", Description: "View scheduled runs"}, + {Type: ViewTypeCronJobs, Name: "Cron Jobs", Description: "View cron jobs"}, + {Type: ViewTypeWebhooks, Name: "Webhooks", Description: "View webhooks"}, } // tuiModel is the root model that manages different views @@ -337,16 +345,20 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Navigate to appropriate view based on detected type var newView tui.View - if msg.Type == "task" { + switch { + case msg.Type == "task" && msg.TaskData != nil: // Single task view taskView := tui.NewSingleTaskView(m.ctx, msg.TaskData.Metadata.Id) taskView.SetSize(m.width, m.height) newView = taskView - } else { + case msg.Type == "dag" && msg.DAGData != nil: // DAG workflow run view dagView := tui.NewRunDetailsView(m.ctx, msg.DAGData.Run.Metadata.Id) dagView.SetSize(m.width, m.height) newView = dagView + default: + // Unknown type or missing data — stay on current view + return m, nil } m.currentView = newView @@ -531,15 +543,20 @@ func (m tuiModel) detectRunType(runID string) tea.Cmd { } } - // Both failed + // Both failed — return the most informative error available if taskResult != nil && taskResult.err != nil { return tui.RunTypeDetectedMsg{ Error: taskResult.err, } } - + if dagResult != nil && dagResult.err != nil { + return tui.RunTypeDetectedMsg{ + Error: dagResult.err, + } + } + // Both returned non-200 without a network error (e.g. run not yet started) return tui.RunTypeDetectedMsg{ - Error: dagResult.err, + Error: fmt.Errorf("run %q not found", runID), } } } @@ -553,6 +570,14 @@ func (m tuiModel) createViewForType(viewType ViewType) tui.View { return tui.NewWorkflowsView(m.ctx) case ViewTypeWorkers: return tui.NewWorkersView(m.ctx) + case ViewTypeRateLimits: + return tui.NewRateLimitsView(m.ctx) + case ViewTypeScheduledRuns: + return tui.NewScheduledRunsView(m.ctx) + case ViewTypeCronJobs: + return tui.NewCronJobsView(m.ctx) + case ViewTypeWebhooks: + return tui.NewWebhooksView(m.ctx) default: return tui.NewRunsListView(m.ctx) } diff --git a/cmd/hatchet-cli/cli/tui/cron_jobs.go b/cmd/hatchet-cli/cli/tui/cron_jobs.go new file mode 100644 index 0000000000..9a552856e6 --- /dev/null +++ b/cmd/hatchet-cli/cli/tui/cron_jobs.go @@ -0,0 +1,323 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +// CronJobsView displays a list of cron jobs in a table +type CronJobsView struct { + lastFetch time.Time + table *TableWithStyleFunc + debugLogger *DebugLogger + cronJobs []rest.CronWorkflows + BaseModel + loading bool + showDebug bool +} + +// cronJobsMsg contains the fetched cron jobs +type cronJobsMsg struct { + err error + debugInfo string + cronJobs []rest.CronWorkflows +} + +// cronJobTickMsg is sent periodically to refresh the data +type cronJobTickMsg time.Time + +// NewCronJobsView creates a new cron jobs list view +func NewCronJobsView(ctx ViewContext) *CronJobsView { + v := &CronJobsView{ + BaseModel: BaseModel{Ctx: ctx}, + loading: false, + debugLogger: NewDebugLogger(5000), + showDebug: false, + } + + columns := []table.Column{ + {Title: "Name", Width: 25}, + {Title: "Expression", Width: 18}, + {Title: "Workflow", Width: 25}, + {Title: "Enabled", Width: 8}, + {Title: "Created At", Width: 18}, + } + + t := NewTableWithStyleFunc( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(20), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.AccentColor). + BorderBottom(true). + Bold(true). + Foreground(styles.AccentColor) + s.Selected = s.Selected. + Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}). + Background(styles.Blue). + Bold(true) + s.Cell = lipgloss.NewStyle() + t.SetStyles(s) + + // Style: color the Enabled column (col 3) green for enabled, red for disabled + t.SetStyleFunc(func(row, col int) lipgloss.Style { + if col == 3 && row < len(v.cronJobs) { + if v.cronJobs[row].Enabled { + return lipgloss.NewStyle().Foreground(styles.StatusSuccessColor) + } + return lipgloss.NewStyle().Foreground(styles.StatusFailedColor) + } + return lipgloss.NewStyle() + }) + + v.table = t + return v +} + +// Init initializes the view +func (v *CronJobsView) Init() tea.Cmd { + return tea.Batch(v.fetchCronJobs(), cronJobTick()) +} + +// Update handles messages and updates the view state +func (v *CronJobsView) Update(msg tea.Msg) (View, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + v.table.SetHeight(msg.Height - 12) + return v, nil + + case tea.KeyMsg: + if v.showDebug { + if handled, debugCmd := HandleDebugKeyboard(v.debugLogger, msg.String()); handled { + return v, debugCmd + } + } + + switch msg.String() { + case "r": + v.loading = true + return v, v.fetchCronJobs() + case "d": + v.showDebug = !v.showDebug + return v, nil + case "c": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.Clear() + } + return v, nil + case "w": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.StartFilePrompt() + } + return v, nil + } + + case cronJobTickMsg: + return v, tea.Batch(v.fetchCronJobs(), cronJobTick()) + + case cronJobsMsg: + v.loading = false + if msg.err != nil { + v.HandleError(msg.err) + v.debugLogger.Log("Error fetching cron jobs: %v", msg.err) + } else { + v.cronJobs = msg.cronJobs + v.updateTableRows() + v.lastFetch = time.Now() + v.ClearError() + v.debugLogger.Log("Fetched %d cron jobs", len(msg.cronJobs)) + } + if msg.debugInfo != "" { + v.debugLogger.Log("API: %s", msg.debugInfo) + } + return v, nil + } + + if mouseMsg, ok := msg.(tea.MouseMsg); ok { + if mouseMsg.Action == tea.MouseActionPress { + switch mouseMsg.Button { + case tea.MouseButtonWheelUp: + if v.table.Cursor() > 0 { + upMsg := tea.KeyMsg{Type: tea.KeyUp} + _, cmd = v.table.Update(upMsg) + return v, cmd + } + case tea.MouseButtonWheelDown: + if v.table.Cursor() < len(v.cronJobs)-1 { + downMsg := tea.KeyMsg{Type: tea.KeyDown} + _, cmd = v.table.Update(downMsg) + return v, cmd + } + } + } + } + + _, cmd = v.table.Update(msg) + return v, cmd +} + +// View renders the view to a string +func (v *CronJobsView) View() string { + if v.Width == 0 { + return "Initializing..." + } + + if v.showDebug { + return RenderDebugView(v.debugLogger, v.Width, v.Height, "") + } + + header := RenderHeaderWithViewIndicator("Cron Jobs", v.Ctx.ProfileName, v.Width) + + statsStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + enabledCount := 0 + for _, cj := range v.cronJobs { + if cj.Enabled { + enabledCount++ + } + } + stats := statsStyle.Render(fmt.Sprintf("Total: %d | Enabled: %d", len(v.cronJobs), enabledCount)) + + loadingText := "" + if v.loading { + loadingStyle := lipgloss.NewStyle().Foreground(styles.AccentColor).Padding(0, 1) + loadingText = loadingStyle.Render("Loading...") + } + + controlItems := []string{ + "↑/↓: Navigate", + "r: Refresh", + "d: Debug", + "h: Help", + "shift+tab: Switch View", + "q: Quit", + } + controls := RenderFooter(controlItems, v.Width) + + var b strings.Builder + b.WriteString(header) + b.WriteString("\n\n") + b.WriteString(stats) + if loadingText != "" { + b.WriteString(" ") + b.WriteString(loadingText) + } + b.WriteString("\n\n") + b.WriteString(v.table.View()) + b.WriteString("\n\n") + + if v.Err != nil { + b.WriteString(RenderError(fmt.Sprintf("Error: %v", v.Err), v.Width)) + b.WriteString("\n") + } + + if !v.lastFetch.IsZero() { + lastFetchStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + b.WriteString(lastFetchStyle.Render(fmt.Sprintf("Last updated: %s", v.lastFetch.Format("15:04:05")))) + b.WriteString("\n") + } + + b.WriteString(controls) + return b.String() +} + +// SetSize updates the view dimensions +func (v *CronJobsView) SetSize(width, height int) { + v.BaseModel.SetSize(width, height) + if height > 12 { + v.table.SetHeight(height - 12) + } +} + +// fetchCronJobs fetches cron jobs from the API +func (v *CronJobsView) fetchCronJobs() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + tenantUUID, err := uuid.Parse(v.Ctx.Client.TenantId()) + if err != nil { + return cronJobsMsg{err: fmt.Errorf("invalid tenant ID: %w", err)} + } + + limit := int64(50) + offset := int64(0) + response, err := v.Ctx.Client.API().CronWorkflowListWithResponse(ctx, tenantUUID, &rest.CronWorkflowListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + return cronJobsMsg{ + err: fmt.Errorf("failed to fetch cron jobs: %w", err), + debugInfo: "Error: " + err.Error(), + } + } + if response.JSON200 == nil { + return cronJobsMsg{ + err: fmt.Errorf("unexpected response from API: status %d", response.StatusCode()), + debugInfo: fmt.Sprintf("Status: %d", response.StatusCode()), + } + } + + cronJobs := []rest.CronWorkflows{} + if response.JSON200.Rows != nil { + cronJobs = *response.JSON200.Rows + } + + return cronJobsMsg{ + cronJobs: cronJobs, + debugInfo: fmt.Sprintf("Fetched %d cron jobs", len(cronJobs)), + } + } +} + +// updateTableRows updates the table rows based on current cron jobs +func (v *CronJobsView) updateTableRows() { + rows := make([]table.Row, len(v.cronJobs)) + + for i, cj := range v.cronJobs { + name := "(no name)" + if cj.Name != nil && *cj.Name != "" { + name = *cj.Name + } + + enabled := "✗" + if cj.Enabled { + enabled = "✓" + } + + createdAt := formatRelativeTime(cj.Metadata.CreatedAt) + + rows[i] = table.Row{ + name, + cj.Cron, + cj.WorkflowName, + enabled, + createdAt, + } + } + + v.table.SetRows(rows) +} + +// cronJobTick returns a command that sends a tick message after a delay +func cronJobTick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return cronJobTickMsg(t) + }) +} diff --git a/cmd/hatchet-cli/cli/tui/rate_limits.go b/cmd/hatchet-cli/cli/tui/rate_limits.go new file mode 100644 index 0000000000..8f405a9b4a --- /dev/null +++ b/cmd/hatchet-cli/cli/tui/rate_limits.go @@ -0,0 +1,319 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +// RateLimitsView displays a list of rate limits in a table +type RateLimitsView struct { + lastFetch time.Time + table *TableWithStyleFunc + debugLogger *DebugLogger + rateLimits []rest.RateLimit + BaseModel + loading bool + showDebug bool +} + +// rateLimitsMsg contains the fetched rate limits +type rateLimitsMsg struct { + err error + debugInfo string + rateLimits []rest.RateLimit +} + +// rateLimitTickMsg is sent periodically to refresh the data +type rateLimitTickMsg time.Time + +// NewRateLimitsView creates a new rate limits list view +func NewRateLimitsView(ctx ViewContext) *RateLimitsView { + v := &RateLimitsView{ + BaseModel: BaseModel{Ctx: ctx}, + loading: false, + debugLogger: NewDebugLogger(5000), + showDebug: false, + } + + columns := []table.Column{ + {Title: "Key", Width: 35}, + {Title: "Value", Width: 10}, + {Title: "Limit", Width: 10}, + {Title: "Usage %", Width: 10}, + {Title: "Window", Width: 12}, + {Title: "Last Refill", Width: 18}, + } + + t := NewTableWithStyleFunc( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(20), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.AccentColor). + BorderBottom(true). + Bold(true). + Foreground(styles.AccentColor) + s.Selected = s.Selected. + Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}). + Background(styles.Blue). + Bold(true) + s.Cell = lipgloss.NewStyle() + t.SetStyles(s) + + // Style: Value is remaining capacity. Red when exhausted (0), yellow when <=10% remaining. + t.SetStyleFunc(func(row, col int) lipgloss.Style { + if row < len(v.rateLimits) { + rl := v.rateLimits[row] + if rl.LimitValue > 0 { + if rl.Value == 0 { + return lipgloss.NewStyle().Foreground(styles.StatusFailedColor) + } + remaining := float64(rl.Value) / float64(rl.LimitValue) + if remaining <= 0.1 { + return lipgloss.NewStyle().Foreground(styles.StatusInProgressColor) + } + } + } + return lipgloss.NewStyle() + }) + + v.table = t + return v +} + +// Init initializes the view +func (v *RateLimitsView) Init() tea.Cmd { + return tea.Batch(v.fetchRateLimits(), rateLimitTick()) +} + +// Update handles messages and updates the view state +func (v *RateLimitsView) Update(msg tea.Msg) (View, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + v.table.SetHeight(msg.Height - 12) + return v, nil + + case tea.KeyMsg: + if v.showDebug { + if handled, debugCmd := HandleDebugKeyboard(v.debugLogger, msg.String()); handled { + return v, debugCmd + } + } + + switch msg.String() { + case "r": + v.loading = true + return v, v.fetchRateLimits() + case "d": + v.showDebug = !v.showDebug + return v, nil + case "c": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.Clear() + } + return v, nil + case "w": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.StartFilePrompt() + } + return v, nil + } + + case rateLimitTickMsg: + return v, tea.Batch(v.fetchRateLimits(), rateLimitTick()) + + case rateLimitsMsg: + v.loading = false + if msg.err != nil { + v.HandleError(msg.err) + v.debugLogger.Log("Error fetching rate limits: %v", msg.err) + } else { + v.rateLimits = msg.rateLimits + v.updateTableRows() + v.lastFetch = time.Now() + v.ClearError() + v.debugLogger.Log("Fetched %d rate limits", len(msg.rateLimits)) + } + if msg.debugInfo != "" { + v.debugLogger.Log("API: %s", msg.debugInfo) + } + return v, nil + } + + if mouseMsg, ok := msg.(tea.MouseMsg); ok { + if mouseMsg.Action == tea.MouseActionPress { + switch mouseMsg.Button { + case tea.MouseButtonWheelUp: + if v.table.Cursor() > 0 { + upMsg := tea.KeyMsg{Type: tea.KeyUp} + _, cmd = v.table.Update(upMsg) + return v, cmd + } + case tea.MouseButtonWheelDown: + if v.table.Cursor() < len(v.rateLimits)-1 { + downMsg := tea.KeyMsg{Type: tea.KeyDown} + _, cmd = v.table.Update(downMsg) + return v, cmd + } + } + } + } + + _, cmd = v.table.Update(msg) + return v, cmd +} + +// View renders the view to a string +func (v *RateLimitsView) View() string { + if v.Width == 0 { + return "Initializing..." + } + + if v.showDebug { + return RenderDebugView(v.debugLogger, v.Width, v.Height, "") + } + + header := RenderHeaderWithViewIndicator("Rate Limits", v.Ctx.ProfileName, v.Width) + + statsStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + stats := statsStyle.Render(fmt.Sprintf("Total: %d", len(v.rateLimits))) + + loadingText := "" + if v.loading { + loadingStyle := lipgloss.NewStyle().Foreground(styles.AccentColor).Padding(0, 1) + loadingText = loadingStyle.Render("Loading...") + } + + controlItems := []string{ + "↑/↓: Navigate", + "r: Refresh", + "d: Debug", + "h: Help", + "shift+tab: Switch View", + "q: Quit", + } + controls := RenderFooter(controlItems, v.Width) + + var b strings.Builder + b.WriteString(header) + b.WriteString("\n\n") + b.WriteString(stats) + if loadingText != "" { + b.WriteString(" ") + b.WriteString(loadingText) + } + b.WriteString("\n\n") + b.WriteString(v.table.View()) + b.WriteString("\n\n") + + if v.Err != nil { + b.WriteString(RenderError(fmt.Sprintf("Error: %v", v.Err), v.Width)) + b.WriteString("\n") + } + + if !v.lastFetch.IsZero() { + lastFetchStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + b.WriteString(lastFetchStyle.Render(fmt.Sprintf("Last updated: %s", v.lastFetch.Format("15:04:05")))) + b.WriteString("\n") + } + + b.WriteString(controls) + return b.String() +} + +// SetSize updates the view dimensions +func (v *RateLimitsView) SetSize(width, height int) { + v.BaseModel.SetSize(width, height) + if height > 12 { + v.table.SetHeight(height - 12) + } +} + +// fetchRateLimits fetches rate limits from the API +func (v *RateLimitsView) fetchRateLimits() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + tenantUUID, err := uuid.Parse(v.Ctx.Client.TenantId()) + if err != nil { + return rateLimitsMsg{err: fmt.Errorf("invalid tenant ID: %w", err)} + } + + limit := int64(100) + response, err := v.Ctx.Client.API().RateLimitListWithResponse(ctx, tenantUUID, &rest.RateLimitListParams{ + Limit: &limit, + }) + if err != nil { + return rateLimitsMsg{ + err: fmt.Errorf("failed to fetch rate limits: %w", err), + debugInfo: "Error: " + err.Error(), + } + } + if response.JSON200 == nil { + return rateLimitsMsg{ + err: fmt.Errorf("unexpected response from API: status %d", response.StatusCode()), + debugInfo: fmt.Sprintf("Status: %d", response.StatusCode()), + } + } + + rateLimits := []rest.RateLimit{} + if response.JSON200.Rows != nil { + rateLimits = *response.JSON200.Rows + } + + return rateLimitsMsg{ + rateLimits: rateLimits, + debugInfo: fmt.Sprintf("Fetched %d rate limits", len(rateLimits)), + } + } +} + +// updateTableRows updates the table rows based on current rate limits +func (v *RateLimitsView) updateTableRows() { + rows := make([]table.Row, len(v.rateLimits)) + + for i, rl := range v.rateLimits { + usagePct := "0%" + if rl.LimitValue > 0 { + pct := float64(rl.Value) / float64(rl.LimitValue) * 100 + usagePct = fmt.Sprintf("%.0f%%", pct) + } + + lastRefill := rl.LastRefill.Format("01/02 15:04:05") + + rows[i] = table.Row{ + rl.Key, + fmt.Sprintf("%d", rl.Value), + fmt.Sprintf("%d", rl.LimitValue), + usagePct, + rl.Window, + lastRefill, + } + } + + v.table.SetRows(rows) +} + +// rateLimitTick returns a command that sends a tick message after a delay +func rateLimitTick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return rateLimitTickMsg(t) + }) +} diff --git a/cmd/hatchet-cli/cli/tui/scheduled_runs.go b/cmd/hatchet-cli/cli/tui/scheduled_runs.go new file mode 100644 index 0000000000..20aac390c1 --- /dev/null +++ b/cmd/hatchet-cli/cli/tui/scheduled_runs.go @@ -0,0 +1,347 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +// ScheduledRunsView displays a list of scheduled runs in a table +type ScheduledRunsView struct { + lastFetch time.Time + table *TableWithStyleFunc + debugLogger *DebugLogger + scheduledRuns []rest.ScheduledWorkflows + BaseModel + loading bool + showDebug bool +} + +// scheduledRunsMsg contains the fetched scheduled runs +type scheduledRunsMsg struct { + err error + debugInfo string + runs []rest.ScheduledWorkflows +} + +// scheduledRunTickMsg is sent periodically to refresh the data +type scheduledRunTickMsg time.Time + +// NewScheduledRunsView creates a new scheduled runs list view +func NewScheduledRunsView(ctx ViewContext) *ScheduledRunsView { + v := &ScheduledRunsView{ + BaseModel: BaseModel{Ctx: ctx}, + loading: false, + debugLogger: NewDebugLogger(5000), + showDebug: false, + } + + columns := []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Status", Width: 14}, + {Title: "Trigger At", Width: 20}, + {Title: "Workflow", Width: 25}, + {Title: "Created At", Width: 18}, + } + + t := NewTableWithStyleFunc( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(20), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.AccentColor). + BorderBottom(true). + Bold(true). + Foreground(styles.AccentColor) + s.Selected = s.Selected. + Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}). + Background(styles.Blue). + Bold(true) + s.Cell = lipgloss.NewStyle() + t.SetStyles(s) + + // Style: color status column by run status + t.SetStyleFunc(func(row, col int) lipgloss.Style { + if col == 1 && row < len(v.scheduledRuns) { + run := v.scheduledRuns[row] + if run.WorkflowRunStatus != nil { + switch string(*run.WorkflowRunStatus) { + case "SUCCEEDED": + return lipgloss.NewStyle().Foreground(styles.StatusSuccessColor) + case "FAILED": + return lipgloss.NewStyle().Foreground(styles.StatusFailedColor) + case "RUNNING": + return lipgloss.NewStyle().Foreground(styles.StatusInProgressColor) + case "CANCELLED": + return lipgloss.NewStyle().Foreground(styles.StatusCancelledColor) + } + } + } + return lipgloss.NewStyle() + }) + + v.table = t + return v +} + +// Init initializes the view +func (v *ScheduledRunsView) Init() tea.Cmd { + return tea.Batch(v.fetchScheduledRuns(), scheduledRunTick()) +} + +// Update handles messages and updates the view state +func (v *ScheduledRunsView) Update(msg tea.Msg) (View, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + v.table.SetHeight(msg.Height - 12) + return v, nil + + case tea.KeyMsg: + if v.showDebug { + if handled, debugCmd := HandleDebugKeyboard(v.debugLogger, msg.String()); handled { + return v, debugCmd + } + } + + switch msg.String() { + case "r": + v.loading = true + return v, v.fetchScheduledRuns() + case "d": + v.showDebug = !v.showDebug + return v, nil + case "c": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.Clear() + } + return v, nil + case "w": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.StartFilePrompt() + } + return v, nil + case "enter": + // Navigate to run details if the scheduled run was triggered + if len(v.scheduledRuns) > 0 { + selectedIdx := v.table.Cursor() + if selectedIdx >= 0 && selectedIdx < len(v.scheduledRuns) { + run := v.scheduledRuns[selectedIdx] + if run.WorkflowRunId != nil { + runID := run.WorkflowRunId.String() + v.debugLogger.Log("Navigating to run: %s", runID) + return v, NewNavigateToRunWithDetectionMsg(runID) + } + } + } + return v, nil + } + + case scheduledRunTickMsg: + return v, tea.Batch(v.fetchScheduledRuns(), scheduledRunTick()) + + case scheduledRunsMsg: + v.loading = false + if msg.err != nil { + v.HandleError(msg.err) + v.debugLogger.Log("Error fetching scheduled runs: %v", msg.err) + } else { + v.scheduledRuns = msg.runs + v.updateTableRows() + v.lastFetch = time.Now() + v.ClearError() + v.debugLogger.Log("Fetched %d scheduled runs", len(msg.runs)) + } + if msg.debugInfo != "" { + v.debugLogger.Log("API: %s", msg.debugInfo) + } + return v, nil + } + + if mouseMsg, ok := msg.(tea.MouseMsg); ok { + if mouseMsg.Action == tea.MouseActionPress { + switch mouseMsg.Button { + case tea.MouseButtonWheelUp: + if v.table.Cursor() > 0 { + upMsg := tea.KeyMsg{Type: tea.KeyUp} + _, cmd = v.table.Update(upMsg) + return v, cmd + } + case tea.MouseButtonWheelDown: + if v.table.Cursor() < len(v.scheduledRuns)-1 { + downMsg := tea.KeyMsg{Type: tea.KeyDown} + _, cmd = v.table.Update(downMsg) + return v, cmd + } + } + } + } + + _, cmd = v.table.Update(msg) + return v, cmd +} + +// View renders the view to a string +func (v *ScheduledRunsView) View() string { + if v.Width == 0 { + return "Initializing..." + } + + if v.showDebug { + return RenderDebugView(v.debugLogger, v.Width, v.Height, "") + } + + header := RenderHeaderWithViewIndicator("Scheduled Runs", v.Ctx.ProfileName, v.Width) + + statsStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + stats := statsStyle.Render(fmt.Sprintf("Total: %d", len(v.scheduledRuns))) + + loadingText := "" + if v.loading { + loadingStyle := lipgloss.NewStyle().Foreground(styles.AccentColor).Padding(0, 1) + loadingText = loadingStyle.Render("Loading...") + } + + controlItems := []string{ + "↑/↓: Navigate", + "enter: View Run", + "r: Refresh", + "d: Debug", + "h: Help", + "shift+tab: Switch View", + "q: Quit", + } + controls := RenderFooter(controlItems, v.Width) + + var b strings.Builder + b.WriteString(header) + b.WriteString("\n\n") + b.WriteString(stats) + if loadingText != "" { + b.WriteString(" ") + b.WriteString(loadingText) + } + b.WriteString("\n\n") + b.WriteString(v.table.View()) + b.WriteString("\n\n") + + if v.Err != nil { + b.WriteString(RenderError(fmt.Sprintf("Error: %v", v.Err), v.Width)) + b.WriteString("\n") + } + + if !v.lastFetch.IsZero() { + lastFetchStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + b.WriteString(lastFetchStyle.Render(fmt.Sprintf("Last updated: %s", v.lastFetch.Format("15:04:05")))) + b.WriteString("\n") + } + + b.WriteString(controls) + return b.String() +} + +// SetSize updates the view dimensions +func (v *ScheduledRunsView) SetSize(width, height int) { + v.BaseModel.SetSize(width, height) + if height > 12 { + v.table.SetHeight(height - 12) + } +} + +// fetchScheduledRuns fetches scheduled runs from the API +func (v *ScheduledRunsView) fetchScheduledRuns() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + tenantUUID, err := uuid.Parse(v.Ctx.Client.TenantId()) + if err != nil { + return scheduledRunsMsg{err: fmt.Errorf("invalid tenant ID: %w", err)} + } + + limit := int64(50) + offset := int64(0) + response, err := v.Ctx.Client.API().WorkflowScheduledListWithResponse(ctx, tenantUUID, &rest.WorkflowScheduledListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + return scheduledRunsMsg{ + err: fmt.Errorf("failed to fetch scheduled runs: %w", err), + debugInfo: "Error: " + err.Error(), + } + } + if response.JSON200 == nil { + return scheduledRunsMsg{ + err: fmt.Errorf("unexpected response from API: status %d", response.StatusCode()), + debugInfo: fmt.Sprintf("Status: %d", response.StatusCode()), + } + } + + runs := []rest.ScheduledWorkflows{} + if response.JSON200.Rows != nil { + runs = *response.JSON200.Rows + } + + return scheduledRunsMsg{ + runs: runs, + debugInfo: fmt.Sprintf("Fetched %d scheduled runs", len(runs)), + } + } +} + +// updateTableRows updates the table rows based on current scheduled runs +func (v *ScheduledRunsView) updateTableRows() { + rows := make([]table.Row, len(v.scheduledRuns)) + + for i, run := range v.scheduledRuns { + // Short ID (first 8 chars) + shortID := run.Metadata.Id + if len(shortID) > 8 { + shortID = shortID[:8] + } + + // Status + status := "Scheduled" + if run.WorkflowRunStatus != nil { + status = string(*run.WorkflowRunStatus) + } + + // Trigger At + triggerAt := run.TriggerAt.Format("01/02 15:04:05") + + // Created At + createdAt := formatRelativeTime(run.Metadata.CreatedAt) + + rows[i] = table.Row{ + shortID, + status, + triggerAt, + run.WorkflowName, + createdAt, + } + } + + v.table.SetRows(rows) +} + +// scheduledRunTick returns a command that sends a tick message after a delay +func scheduledRunTick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return scheduledRunTickMsg(t) + }) +} diff --git a/cmd/hatchet-cli/cli/tui/webhooks.go b/cmd/hatchet-cli/cli/tui/webhooks.go new file mode 100644 index 0000000000..7c0aa0c48c --- /dev/null +++ b/cmd/hatchet-cli/cli/tui/webhooks.go @@ -0,0 +1,295 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +// WebhooksView displays a list of V1 webhooks in a table +type WebhooksView struct { + lastFetch time.Time + table *TableWithStyleFunc + debugLogger *DebugLogger + webhooks []rest.V1Webhook + BaseModel + loading bool + showDebug bool +} + +// webhooksMsg contains the fetched webhooks +type webhooksMsg struct { + err error + debugInfo string + webhooks []rest.V1Webhook +} + +// webhookTickMsg is sent periodically to refresh the data +type webhookTickMsg time.Time + +// NewWebhooksView creates a new webhooks list view +func NewWebhooksView(ctx ViewContext) *WebhooksView { + v := &WebhooksView{ + BaseModel: BaseModel{Ctx: ctx}, + loading: false, + debugLogger: NewDebugLogger(5000), + showDebug: false, + } + + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Source", Width: 20}, + {Title: "Created At", Width: 18}, + } + + t := NewTableWithStyleFunc( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(20), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.AccentColor). + BorderBottom(true). + Bold(true). + Foreground(styles.AccentColor) + s.Selected = s.Selected. + Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}). + Background(styles.Blue). + Bold(true) + s.Cell = lipgloss.NewStyle() + t.SetStyles(s) + + t.SetStyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle() + }) + + v.table = t + return v +} + +// Init initializes the view +func (v *WebhooksView) Init() tea.Cmd { + return tea.Batch(v.fetchWebhooks(), webhookTick()) +} + +// Update handles messages and updates the view state +func (v *WebhooksView) Update(msg tea.Msg) (View, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + v.table.SetHeight(msg.Height - 12) + return v, nil + + case tea.KeyMsg: + if v.showDebug { + if handled, debugCmd := HandleDebugKeyboard(v.debugLogger, msg.String()); handled { + return v, debugCmd + } + } + + switch msg.String() { + case "r": + v.loading = true + return v, v.fetchWebhooks() + case "d": + v.showDebug = !v.showDebug + return v, nil + case "c": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.Clear() + } + return v, nil + case "w": + if v.showDebug && !v.debugLogger.IsPromptingFile() { + v.debugLogger.StartFilePrompt() + } + return v, nil + } + + case webhookTickMsg: + return v, tea.Batch(v.fetchWebhooks(), webhookTick()) + + case webhooksMsg: + v.loading = false + if msg.err != nil { + v.HandleError(msg.err) + v.debugLogger.Log("Error fetching webhooks: %v", msg.err) + } else { + v.webhooks = msg.webhooks + v.updateTableRows() + v.lastFetch = time.Now() + v.ClearError() + v.debugLogger.Log("Fetched %d webhooks", len(msg.webhooks)) + } + if msg.debugInfo != "" { + v.debugLogger.Log("API: %s", msg.debugInfo) + } + return v, nil + } + + if mouseMsg, ok := msg.(tea.MouseMsg); ok { + if mouseMsg.Action == tea.MouseActionPress { + switch mouseMsg.Button { + case tea.MouseButtonWheelUp: + if v.table.Cursor() > 0 { + upMsg := tea.KeyMsg{Type: tea.KeyUp} + _, cmd = v.table.Update(upMsg) + return v, cmd + } + case tea.MouseButtonWheelDown: + if v.table.Cursor() < len(v.webhooks)-1 { + downMsg := tea.KeyMsg{Type: tea.KeyDown} + _, cmd = v.table.Update(downMsg) + return v, cmd + } + } + } + } + + _, cmd = v.table.Update(msg) + return v, cmd +} + +// View renders the view to a string +func (v *WebhooksView) View() string { + if v.Width == 0 { + return "Initializing..." + } + + if v.showDebug { + return RenderDebugView(v.debugLogger, v.Width, v.Height, "") + } + + header := RenderHeaderWithViewIndicator("Webhooks", v.Ctx.ProfileName, v.Width) + + statsStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + stats := statsStyle.Render(fmt.Sprintf("Total: %d", len(v.webhooks))) + + loadingText := "" + if v.loading { + loadingStyle := lipgloss.NewStyle().Foreground(styles.AccentColor).Padding(0, 1) + loadingText = loadingStyle.Render("Loading...") + } + + controlItems := []string{ + "↑/↓: Navigate", + "r: Refresh", + "d: Debug", + "h: Help", + "shift+tab: Switch View", + "q: Quit", + } + controls := RenderFooter(controlItems, v.Width) + + var b strings.Builder + b.WriteString(header) + b.WriteString("\n\n") + b.WriteString(stats) + if loadingText != "" { + b.WriteString(" ") + b.WriteString(loadingText) + } + b.WriteString("\n\n") + b.WriteString(v.table.View()) + b.WriteString("\n\n") + + if v.Err != nil { + b.WriteString(RenderError(fmt.Sprintf("Error: %v", v.Err), v.Width)) + b.WriteString("\n") + } + + if !v.lastFetch.IsZero() { + lastFetchStyle := lipgloss.NewStyle().Foreground(styles.MutedColor).Padding(0, 1) + b.WriteString(lastFetchStyle.Render(fmt.Sprintf("Last updated: %s", v.lastFetch.Format("15:04:05")))) + b.WriteString("\n") + } + + b.WriteString(controls) + return b.String() +} + +// SetSize updates the view dimensions +func (v *WebhooksView) SetSize(width, height int) { + v.BaseModel.SetSize(width, height) + if height > 12 { + v.table.SetHeight(height - 12) + } +} + +// fetchWebhooks fetches webhooks from the API +func (v *WebhooksView) fetchWebhooks() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + tenantUUID, err := uuid.Parse(v.Ctx.Client.TenantId()) + if err != nil { + return webhooksMsg{err: fmt.Errorf("invalid tenant ID: %w", err)} + } + + limit := int64(200) + offset := int64(0) + response, err := v.Ctx.Client.API().V1WebhookListWithResponse(ctx, tenantUUID, &rest.V1WebhookListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + return webhooksMsg{ + err: fmt.Errorf("failed to fetch webhooks: %w", err), + debugInfo: "Error: " + err.Error(), + } + } + if response.JSON200 == nil { + return webhooksMsg{ + err: fmt.Errorf("unexpected response from API: status %d", response.StatusCode()), + debugInfo: fmt.Sprintf("Status: %d", response.StatusCode()), + } + } + + webhooks := []rest.V1Webhook{} + if response.JSON200.Rows != nil { + webhooks = *response.JSON200.Rows + } + + return webhooksMsg{ + webhooks: webhooks, + debugInfo: fmt.Sprintf("Fetched %d webhooks", len(webhooks)), + } + } +} + +// updateTableRows updates the table rows based on current webhooks +func (v *WebhooksView) updateTableRows() { + rows := make([]table.Row, len(v.webhooks)) + + for i, wh := range v.webhooks { + createdAt := formatRelativeTime(wh.Metadata.CreatedAt) + rows[i] = table.Row{ + wh.Name, + string(wh.SourceName), + createdAt, + } + } + + v.table.SetRows(rows) +} + +// webhookTick returns a command that sends a tick message after a delay +func webhookTick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return webhookTickMsg(t) + }) +} diff --git a/cmd/hatchet-cli/cli/webhooks.go b/cmd/hatchet-cli/cli/webhooks.go new file mode 100644 index 0000000000..bb065d6a21 --- /dev/null +++ b/cmd/hatchet-cli/cli/webhooks.go @@ -0,0 +1,69 @@ +package cli + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var webhooksCmd = &cobra.Command{ + Use: "webhooks", + Aliases: []string{"webhook"}, + Short: "Manage webhooks", + Long: `Commands for listing and inspecting webhooks.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, +} + +var webhooksListCmd = &cobra.Command{ + Use: "list", + Short: "List webhooks", + Long: `List webhooks. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet webhooks list --profile local + + # JSON output + hatchet webhooks list -o json`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeWebhooks + tuiM.currentView = tui.NewWebhooksView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + limit := int64(50) + offset := int64(0) + resp, err := hatchetClient.API().V1WebhookListWithResponse(ctx, tenantUUID, &rest.V1WebhookListParams{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + cli.Logger.Fatalf("failed to list webhooks: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +func init() { + rootCmd.AddCommand(webhooksCmd) + webhooksCmd.AddCommand(webhooksListCmd) + + webhooksCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + webhooksCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") +} diff --git a/cmd/hatchet-cli/cli/webhooks_e2e_test.go b/cmd/hatchet-cli/cli/webhooks_e2e_test.go new file mode 100644 index 0000000000..83ba24565f --- /dev/null +++ b/cmd/hatchet-cli/cli/webhooks_e2e_test.go @@ -0,0 +1,41 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestWebhooksListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("webhooks", "list") + + var result map[string]interface{} + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal webhooks list output: %v\nOutput: %s", err, out) + } + + if result == nil { + t.Error("expected non-nil JSON response from webhooks list") + } +} + +func TestWebhooksTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("webhooks", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Webhooks", "webhooks") { + t.Errorf("expected TUI to show 'Webhooks' header; got:\n%s", content) + } +} diff --git a/cmd/hatchet-cli/cli/worker.go b/cmd/hatchet-cli/cli/worker.go index 9a1bbb23d7..b56300634b 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -7,13 +7,16 @@ import ( "os" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/google/uuid" "github.com/spf13/cobra" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/worker" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/pm" "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" "github.com/hatchet-dev/hatchet/pkg/cmdutils" profileconfig "github.com/hatchet-dev/hatchet/pkg/config/cli" ) @@ -75,15 +78,125 @@ var devCmd = &cobra.Command{ }, } +var workerListCmd = &cobra.Command{ + Use: "list", + Short: "List workers", + Long: `List workers. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet worker list --profile local + + # JSON output + hatchet worker list -o json`, + // Override parent PersistentPreRun — no hatchet.yaml required for listing + PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeWorkers + tuiM.currentView = tui.NewWorkersView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + resp, err := hatchetClient.API().WorkerListWithResponse(ctx, tenantUUID) + if err != nil { + cli.Logger.Fatalf("failed to list workers: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var workerGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get worker details", + Long: `Get details about a worker. Without --output json, launches the TUI navigated to the worker. With --output json, outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Example: ` # Launch TUI for a specific worker + hatchet worker get --profile local + + # JSON output + hatchet worker get -o json`, + // Override parent PersistentPreRun — no hatchet.yaml required for get + PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + Run: func(cmd *cobra.Command, args []string) { + workerID := args[0] + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + base := newTUIModel(selectedProfile, hatchetClient) + base.currentViewType = ViewTypeWorkers + base.currentView = tui.NewWorkersView(base.ctx) + model := tuiModelWithInitialWorker{ + tuiModel: base, + initialWorkerID: workerID, + } + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + workerUUID, err := uuid.Parse(workerID) + if err != nil { + cli.Logger.Fatalf("invalid worker ID %q: %v", workerID, err) + } + + ctx := cmd.Context() + resp, err := hatchetClient.API().WorkerGetWithResponse(ctx, workerUUID) + if err != nil { + cli.Logger.Fatalf("failed to get worker: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("worker not found (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +// tuiModelWithInitialWorker wraps tuiModel to navigate to a specific worker on init +type tuiModelWithInitialWorker struct { + initialWorkerID string + tuiModel +} + +func (m tuiModelWithInitialWorker) Init() tea.Cmd { + return tea.Batch( + m.tuiModel.Init(), + func() tea.Msg { return tui.NavigateToWorkerMsg{WorkerID: m.initialWorkerID} }, + ) +} + func init() { rootCmd.AddCommand(workerCmd) workerCmd.AddCommand(devCmd) + workerCmd.AddCommand(workerListCmd, workerGetCmd) // Add flags for dev command devCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") devCmd.Flags().Bool("no-reload", false, "Disable automatic reloading on file changes") devCmd.Flags().StringP("run-cmd", "r", "", "Override the run command from hatchet.yaml") + + // Add flags for list/get commands + workerListCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + workerListCmd.Flags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + workerGetCmd.Flags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + workerGetCmd.Flags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") } func startWorker(cmd *cobra.Command, devConfig *worker.WorkerDevConfig, profileFlag string) { diff --git a/cmd/hatchet-cli/cli/workers_e2e_test.go b/cmd/hatchet-cli/cli/workers_e2e_test.go new file mode 100644 index 0000000000..c0901f186f --- /dev/null +++ b/cmd/hatchet-cli/cli/workers_e2e_test.go @@ -0,0 +1,66 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestWorkerListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("worker", "list") + + var result struct { + Rows []map[string]interface{} `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal worker list output: %v\nOutput: %s", err, out) + } + + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } +} + +func TestWorkerGetJSON(t *testing.T) { + workerID := os.Getenv("HATCHET_TEST_WORKER_ID") + if workerID == "" { + t.Skip("HATCHET_TEST_WORKER_ID not set; skipping worker get test") + } + + h := testharness.New(t) + out := h.RunJSON("worker", "get", workerID) + + var result struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal worker get output: %v\nOutput: %s", err, out) + } + if result.Metadata.ID != workerID { + t.Errorf("expected metadata.id = %q, got %q", workerID, result.Metadata.ID) + } +} + +func TestWorkersTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("worker", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Workers", "workers") { + t.Errorf("expected TUI to show 'Workers' header; got:\n%s", content) + } +} diff --git a/cmd/hatchet-cli/cli/workflows.go b/cmd/hatchet-cli/cli/workflows.go new file mode 100644 index 0000000000..dc01c4b447 --- /dev/null +++ b/cmd/hatchet-cli/cli/workflows.go @@ -0,0 +1,143 @@ +package cli + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/config/cli" + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/tui" + "github.com/hatchet-dev/hatchet/pkg/client/rest" +) + +var workflowsCmd = &cobra.Command{ + Use: "workflows", + Aliases: []string{"workflow"}, + Short: "Manage workflows", + Long: `Commands for listing and inspecting workflows.`, + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, +} + +var workflowsListCmd = &cobra.Command{ + Use: "list", + Short: "List workflows", + Long: `List workflows. Without --output json, launches the interactive TUI. With --output json, outputs raw JSON.`, + Example: ` # Launch interactive TUI (default) + hatchet workflows list --profile local + + # JSON output + hatchet workflows list -o json + hatchet workflows list -o json --search my-workflow --limit 100`, + Run: func(cmd *cobra.Command, args []string) { + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + tuiM := newTUIModel(selectedProfile, hatchetClient) + tuiM.currentViewType = ViewTypeWorkflows + tuiM.currentView = tui.NewWorkflowsView(tuiM.ctx) + p := tea.NewProgram(tuiM, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + ctx := cmd.Context() + tenantUUID := clientTenantUUID(hatchetClient) + searchStr, _ := cmd.Flags().GetString("search") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + + params := &rest.WorkflowListParams{ + Limit: &limit, + Offset: &offset, + } + if searchStr != "" { + params.Name = &searchStr + } + + resp, err := hatchetClient.API().WorkflowListWithResponse(ctx, tenantUUID, params) + if err != nil { + cli.Logger.Fatalf("failed to list workflows: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("unexpected response from API (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +var workflowsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get workflow details", + Long: `Get details about a workflow. Without --output json, launches the TUI navigated to the workflow. With --output json, outputs raw JSON.`, + Args: cobra.ExactArgs(1), + Example: ` # Launch TUI for a specific workflow + hatchet workflows get --profile local + + # JSON output + hatchet workflows get -o json`, + Run: func(cmd *cobra.Command, args []string) { + workflowID := args[0] + isJSON := isJSONOutput(cmd) + selectedProfile, hatchetClient := clientFromCmd(cmd) + + if !isJSON { + base := newTUIModel(selectedProfile, hatchetClient) + base.currentViewType = ViewTypeWorkflows + base.currentView = tui.NewWorkflowsView(base.ctx) + model := tuiModelWithInitialWorkflow{ + tuiModel: base, + initialWorkflowID: workflowID, + } + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + cli.Logger.Fatalf("error running TUI: %v", err) + } + return + } + + workflowUUID, err := uuid.Parse(workflowID) + if err != nil { + cli.Logger.Fatalf("invalid workflow ID %q: %v", workflowID, err) + } + + ctx := cmd.Context() + resp, err := hatchetClient.API().WorkflowGetWithResponse(ctx, workflowUUID) + if err != nil { + cli.Logger.Fatalf("failed to get workflow: %v", err) + } + if resp.JSON200 == nil { + cli.Logger.Fatalf("workflow not found (status %d)", resp.StatusCode()) + } + + printJSON(resp.JSON200) + }, +} + +// tuiModelWithInitialWorkflow wraps tuiModel to navigate to a specific workflow on init +type tuiModelWithInitialWorkflow struct { + initialWorkflowID string + tuiModel +} + +func (m tuiModelWithInitialWorkflow) Init() tea.Cmd { + return tea.Batch( + m.tuiModel.Init(), + func() tea.Msg { return tui.NavigateToWorkflowMsg{WorkflowID: m.initialWorkflowID} }, + ) +} + +func init() { + rootCmd.AddCommand(workflowsCmd) + workflowsCmd.AddCommand(workflowsListCmd, workflowsGetCmd) + + workflowsCmd.PersistentFlags().StringP("profile", "p", "", "Profile to use for connecting to Hatchet (default: prompts for selection)") + workflowsCmd.PersistentFlags().StringP("output", "o", "", "Output format: json (skips interactive TUI)") + + workflowsListCmd.Flags().StringP("search", "s", "", "Search workflows by name") + workflowsListCmd.Flags().Int("limit", 50, "Number of results to return") + workflowsListCmd.Flags().Int("offset", 0, "Offset for pagination") +} diff --git a/cmd/hatchet-cli/cli/workflows_e2e_test.go b/cmd/hatchet-cli/cli/workflows_e2e_test.go new file mode 100644 index 0000000000..724b1d2c34 --- /dev/null +++ b/cmd/hatchet-cli/cli/workflows_e2e_test.go @@ -0,0 +1,78 @@ +//go:build e2e_cli + +package cli + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/testharness" +) + +func TestWorkflowsListJSON(t *testing.T) { + h := testharness.New(t) + out := h.RunJSON("workflows", "list") + + var result struct { + Rows []map[string]interface{} `json:"rows"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal workflows list output: %v\nOutput: %s", err, out) + } + + // rows may be empty, but the field must exist + if result.Rows == nil { + t.Errorf("expected 'rows' array in response, got nil") + } +} + +func TestWorkflowsGetJSON(t *testing.T) { + workflowID := os.Getenv("HATCHET_TEST_WORKFLOW_ID") + if workflowID == "" { + t.Skip("HATCHET_TEST_WORKFLOW_ID not set; skipping workflows get test") + } + + h := testharness.New(t) + out := h.RunJSON("workflows", "get", workflowID) + + var result struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to unmarshal workflows get output: %v\nOutput: %s", err, out) + } + if result.Metadata.ID != workflowID { + t.Errorf("expected metadata.id = %q, got %q", workflowID, result.Metadata.ID) + } +} + +func TestWorkflowsTUI(t *testing.T) { + h := testharness.New(t) + tui := testharness.NewTUI(t, h) + t.Cleanup(tui.Stop) + + tui.Start("workflows", "list") + content := tui.CaptureAfter(3 * time.Second) + + if content == "" { + t.Fatal("TUI output was empty") + } + if !containsAny(content, "Workflows", "workflows") { + t.Errorf("expected TUI to show 'Workflows' header; got:\n%s", content) + } +} + +// containsAny reports whether s contains any of the given substrings. +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/cmd/hatchet-engine/main.go b/cmd/hatchet-engine/main.go index 593d268be8..45bc0c4e14 100644 --- a/cmd/hatchet-engine/main.go +++ b/cmd/hatchet-engine/main.go @@ -46,7 +46,8 @@ var rootCmd = &cobra.Command{ } // Version will be linked by an ldflag during build -var Version = "v0.1.0-alpha.0" +// FIXME: automate this version update on tag, we use it to version the engine for sdks +var Version = "v0.78.23" func main() { rootCmd.PersistentFlags().BoolVar( diff --git a/cmd/hatchet-migrate/migrate/migrations/20260216000001_v1_0_79_a_multi_slot_schema_and_triggers.sql b/cmd/hatchet-migrate/migrate/migrations/20260216000001_v1_0_79_a_multi_slot_schema_and_triggers.sql index 7e47c46142..099a1d83fd 100644 --- a/cmd/hatchet-migrate/migrate/migrations/20260216000001_v1_0_79_a_multi_slot_schema_and_triggers.sql +++ b/cmd/hatchet-migrate/migrate/migrations/20260216000001_v1_0_79_a_multi_slot_schema_and_triggers.sql @@ -113,8 +113,14 @@ BEGIN worker_id, 'default'::text, 1 - FROM new_rows - WHERE worker_id IS NOT NULL + FROM new_rows nr + WHERE nr.worker_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM v1_task_runtime_slot s + WHERE s.task_id = nr.task_id + AND s.task_inserted_at = nr.task_inserted_at + AND s.retry_count = nr.retry_count + ) ON CONFLICT (task_id, task_inserted_at, retry_count, slot_type) DO NOTHING; RETURN NULL; diff --git a/cmd/hatchet-migrate/migrate/migrations/20260216000004_v1_0_80_fix_slot_trigger.sql b/cmd/hatchet-migrate/migrate/migrations/20260216000004_v1_0_80_fix_slot_trigger.sql new file mode 100644 index 0000000000..c0cdbbc6a1 --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20260216000004_v1_0_80_fix_slot_trigger.sql @@ -0,0 +1,79 @@ +-- +goose Up +-- +goose StatementBegin + +-- The original trigger always inserted a 'default' slot into v1_task_runtime_slot +-- when a row was inserted into v1_task_runtime. This caused durable tasks to also +-- acquire a default slot because the new code path (assigned_slots CTE) already +-- inserts the correct slot type, but the trigger would fire afterwards and add +-- an extra 'default' row. The NOT EXISTS guard skips the trigger insert when the +-- new code path has already written slots for the task. +CREATE OR REPLACE FUNCTION v1_task_runtime_slot_insert_function() +RETURNS TRIGGER AS +$$ +BEGIN + INSERT INTO v1_task_runtime_slot ( + tenant_id, + task_id, + task_inserted_at, + retry_count, + worker_id, + slot_type, + units + ) + SELECT + tenant_id, + task_id, + task_inserted_at, + retry_count, + worker_id, + 'default'::text, + 1 + FROM new_rows nr + WHERE nr.worker_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM v1_task_runtime_slot s + WHERE s.task_id = nr.task_id + AND s.task_inserted_at = nr.task_inserted_at + AND s.retry_count = nr.retry_count + ) + ON CONFLICT (task_id, task_inserted_at, retry_count, slot_type) DO NOTHING; + + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION v1_task_runtime_slot_insert_function() +RETURNS TRIGGER AS +$$ +BEGIN + INSERT INTO v1_task_runtime_slot ( + tenant_id, + task_id, + task_inserted_at, + retry_count, + worker_id, + slot_type, + units + ) + SELECT + tenant_id, + task_id, + task_inserted_at, + retry_count, + worker_id, + 'default'::text, + 1 + FROM new_rows + WHERE worker_id IS NOT NULL + ON CONFLICT (task_id, task_inserted_at, retry_count, slot_type) DO NOTHING; + + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; +-- +goose StatementEnd diff --git a/cmd/hatchet-migrate/migrate/migrations/20260219000001_v1_0_81.sql b/cmd/hatchet-migrate/migrate/migrations/20260219000001_v1_0_81.sql new file mode 100644 index 0000000000..ce05c1a9dc --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20260219000001_v1_0_81.sql @@ -0,0 +1,7 @@ +-- +goose NO TRANSACTION + +-- +goose Up +CREATE INDEX CONCURRENTLY IF NOT EXISTS v1_worker_slot_config_worker_id_idx ON v1_worker_slot_config (worker_id); + +-- +goose Down +DROP INDEX IF EXISTS v1_worker_slot_config_worker_id_idx; diff --git a/examples/python/cancellation/worker.py b/examples/python/cancellation/worker.py index bd31fe9fab..47d84f6cf2 100644 --- a/examples/python/cancellation/worker.py +++ b/examples/python/cancellation/worker.py @@ -1,7 +1,7 @@ import asyncio import time -from hatchet_sdk import CancellationReason, CancelledError, Context, EmptyModel, Hatchet +from hatchet_sdk import Context, EmptyModel, Hatchet hatchet = Hatchet(debug=True) @@ -40,25 +40,6 @@ def check_flag(input: EmptyModel, ctx: Context) -> dict[str, str]: -# > Handling cancelled error -@cancellation_workflow.task() -def my_task(input: EmptyModel, ctx: Context) -> dict: - try: - result = ctx.playground("test", "default") - except CancelledError as e: - # Handle parent cancellation - i.e. perform cleanup, then re-raise - print(f"Parent Task cancelled: {e.reason}") - # Always re-raise CancelledError so Hatchet can properly handle the cancellation - raise - except Exception as e: - # This will NOT catch CancelledError - print(f"Other error: {e}") - raise - return result - - - - def main() -> None: worker = hatchet.worker("cancellation-worker", workflows=[cancellation_workflow]) worker.start() diff --git a/examples/python/quickstart/poetry.lock b/examples/python/quickstart/poetry.lock index 2d8d7c34b2..1b61b19887 100644 --- a/examples/python/quickstart/poetry.lock +++ b/examples/python/quickstart/poetry.lock @@ -473,14 +473,14 @@ setuptools = "*" [[package]] name = "hatchet-sdk" -version = "1.24.0" +version = "1.25.2" description = "This is the official Python SDK for Hatchet, a distributed, fault-tolerant task queue. The SDK allows you to easily integrate Hatchet's task scheduling and workflow orchestration capabilities into your Python applications." optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "hatchet_sdk-1.24.0-py3-none-any.whl", hash = "sha256:6719947bcf3ee954966f5c403f3217b05f3a8829a54eddc3a12c982863d53c4c"}, - {file = "hatchet_sdk-1.24.0.tar.gz", hash = "sha256:e39bdb4e7013e98f5354dba046cfe14f9284bf835a2f0ca67613efadcac3e180"}, + {file = "hatchet_sdk-1.25.2-py3-none-any.whl", hash = "sha256:057964f451636a46f2365e550a8eeebefc75f13daf412a84ec739874b6ae4191"}, + {file = "hatchet_sdk-1.25.2.tar.gz", hash = "sha256:879f7c0e2e20cb17e58df787bd9f160870a592567924d0bec673ab7df071632a"}, ] [package.dependencies] @@ -1125,4 +1125,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "665009b2127a5e046ab48cb29fac59dd00bf17ab45f53a8c897bf8bf62d6bc57" +content-hash = "9f8a336bf99bd769d22743dcec55bfc1d35df37c0bbfa544f3a8178ea4ad08df" diff --git a/examples/python/quickstart/pyproject.toml b/examples/python/quickstart/pyproject.toml index acf7292af2..7b7d809928 100644 --- a/examples/python/quickstart/pyproject.toml +++ b/examples/python/quickstart/pyproject.toml @@ -8,7 +8,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.10" -hatchet-sdk = "1.24.0" +hatchet-sdk = "1.25.2" [build-system] diff --git a/examples/python/simple/worker.py b/examples/python/simple/worker.py index 3dbb7d1cca..85bb98a8ce 100644 --- a/examples/python/simple/worker.py +++ b/examples/python/simple/worker.py @@ -1,5 +1,5 @@ # > Simple -from hatchet_sdk import Context, DurableContext, EmptyModel, Hatchet +from hatchet_sdk import Context, EmptyModel, Hatchet hatchet = Hatchet(debug=True) @@ -10,9 +10,7 @@ def simple(input: EmptyModel, ctx: Context) -> dict[str, str]: @hatchet.durable_task() -async def simple_durable(input: EmptyModel, ctx: DurableContext) -> dict[str, str]: - res = await simple.aio_run(input) - print(res) +def simple_durable(input: EmptyModel, ctx: Context) -> dict[str, str]: return {"result": "Hello, world!"} diff --git a/frontend/app/package.json b/frontend/app/package.json index d0aca78815..f6c369f431 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -53,7 +53,6 @@ "@tanstack/react-router-devtools": "^1.141.0", "@tanstack/react-table": "^8.21.2", "@types/lodash": "^4.17.20", - "ansi-to-html": "^0.7.2", "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml index 2d0bd71d46..e588a1e0a2 100644 --- a/frontend/app/pnpm-lock.yaml +++ b/frontend/app/pnpm-lock.yaml @@ -111,9 +111,6 @@ importers: '@types/lodash': specifier: ^4.17.20 version: 4.17.20 - ansi-to-html: - specifier: ^0.7.2 - version: 0.7.2 axios: specifier: ^1.12.0 version: 1.12.0 @@ -1877,11 +1874,6 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - ansi-to-html@0.7.2: - resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} - engines: {node: '>=8.0.0'} - hasBin: true - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2366,9 +2358,6 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -6042,10 +6031,6 @@ snapshots: ansi-styles@6.2.1: {} - ansi-to-html@0.7.2: - dependencies: - entities: 2.2.0 - any-promise@1.3.0: {} anymatch@3.1.3: @@ -6578,8 +6563,6 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - entities@2.2.0: {} - es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 diff --git a/frontend/app/src/components/v1/cloud/logging/ansi-line.tsx b/frontend/app/src/components/v1/cloud/logging/ansi-line.tsx new file mode 100644 index 0000000000..2475d95de9 --- /dev/null +++ b/frontend/app/src/components/v1/cloud/logging/ansi-line.tsx @@ -0,0 +1,188 @@ +import { CSSProperties } from 'react'; + +// ---- ANSI parser (adapted from react-logviewer/src/components/Utils/ansiparse.ts) ---- + +interface AnsiPart { + text: string; + foreground?: string; + background?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; +} + +type AnsiState = Omit; + +const FOREGROUND_COLORS: Record = { + '30': 'black', + '31': 'red', + '32': 'green', + '33': 'yellow', + '34': 'blue', + '35': 'magenta', + '36': 'cyan', + '37': 'white', + '90': 'grey', +}; + +const BACKGROUND_COLORS: Record = { + '40': 'black', + '41': 'red', + '42': 'green', + '43': 'yellow', + '44': 'blue', + '45': 'magenta', + '46': 'cyan', + '47': 'white', +}; + +function ansiparse(str: string): AnsiPart[] { + let matchingControl: string | null = null; + let matchingData: string | null = null; + let matchingText = ''; + let ansiState: string[] = []; + const result: AnsiPart[] = []; + let state: AnsiState = {}; + + for (let i = 0; i < str.length; i++) { + if (matchingControl !== null) { + if (matchingControl === '\x1b' && str[i] === '[') { + if (matchingText) { + result.push({ text: matchingText, ...state }); + state = {}; + matchingText = ''; + } + matchingControl = null; + matchingData = ''; + } else { + matchingText += matchingControl + str[i]; + matchingControl = null; + } + continue; + } + + if (matchingData !== null) { + if (str[i] === ';') { + ansiState.push(matchingData); + matchingData = ''; + } else if (str[i] === 'm') { + ansiState.push(matchingData); + matchingData = null; + matchingText = ''; + + for (const code of ansiState) { + if (FOREGROUND_COLORS[code]) { + state.foreground = FOREGROUND_COLORS[code]; + } else if (BACKGROUND_COLORS[code]) { + state.background = BACKGROUND_COLORS[code]; + } else if (code === '39') { + delete state.foreground; + } else if (code === '49') { + delete state.background; + } else if (code === '1') { + state.bold = true; + } else if (code === '3') { + state.italic = true; + } else if (code === '4') { + state.underline = true; + } else if (code === '22') { + state.bold = false; + } else if (code === '23') { + state.italic = false; + } else if (code === '24') { + state.underline = false; + } + } + ansiState = []; + } else { + matchingData += str[i]; + } + continue; + } + + if (str[i] === '\x1b') { + matchingControl = str[i]; + } else if (str[i] === '\u0008') { + // Backspace: erase previous character + if (matchingText.length) { + matchingText = matchingText.slice(0, -1); + } else if (result.length) { + const last = result[result.length - 1]; + if (last.text.length === 1) { + result.pop(); + } else { + last.text = last.text.slice(0, -1); + } + } + } else { + matchingText += str[i]; + } + } + + if (matchingText) { + result.push({ text: matchingText + (matchingControl ?? ''), ...state }); + } + + return result; +} + +// ---- Color mapping to existing CSS variables ---- + +const FOREGROUND_CSS_VARS: Record = { + red: 'var(--terminal-red)', + green: 'var(--terminal-green)', + yellow: 'var(--terminal-yellow)', + blue: 'var(--terminal-blue)', + magenta: 'var(--terminal-magenta)', + cyan: 'var(--terminal-cyan)', +}; + +function partStyle(part: AnsiPart): CSSProperties | undefined { + const style: CSSProperties = {}; + let hasStyle = false; + + const fg = part.foreground && FOREGROUND_CSS_VARS[part.foreground]; + if (fg) { + style.color = fg; + hasStyle = true; + } + if (part.bold) { + style.fontWeight = 'bold'; + hasStyle = true; + } + if (part.italic) { + style.fontStyle = 'italic'; + hasStyle = true; + } + if (part.underline) { + style.textDecoration = 'underline'; + hasStyle = true; + } + + return hasStyle ? style : undefined; +} + +// ---- Component ---- + +export function AnsiLine({ text }: { text: string }) { + const parts = ansiparse(text); + + if (parts.length === 0) { + return null; + } + + return ( + <> + {parts.map((part, i) => { + const style = partStyle(part); + return style ? ( + + {part.text} + + ) : ( + part.text + ); + })} + + ); +} diff --git a/frontend/app/src/components/v1/cloud/logging/components/Terminal.tsx b/frontend/app/src/components/v1/cloud/logging/components/Terminal.tsx deleted file mode 100644 index bce818909f..0000000000 --- a/frontend/app/src/components/v1/cloud/logging/components/Terminal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { cn } from '@/lib/utils'; -import { LazyLog, ScrollFollow } from '@melloware/react-logviewer'; -import { useCallback, useRef } from 'react'; - -interface TerminalProps { - logs: string; - autoScroll?: boolean; - onScrollToTop?: () => void; - onScrollToBottom?: () => void; - onAtTopChange?: (atTop: boolean) => void; - className?: string; -} - -function Terminal({ - logs, - autoScroll = false, - onScrollToTop, - onScrollToBottom, - onAtTopChange, - className, -}: TerminalProps) { - const lastScrollTopRef = useRef(0); - const wasAtTopRef = useRef(true); - const wasInTopRegionRef = useRef(false); - const wasInBottomRegionRef = useRef(false); - - const handleLineClick = useCallback( - (event: React.MouseEvent) => { - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - return; - } - - const lineElement = (event.target as HTMLElement).closest('.log-line'); - if (lineElement) { - lineElement.classList.toggle('expanded'); - } - }, - [], - ); - - const handleScroll = useCallback( - ({ - scrollTop, - scrollHeight, - clientHeight, - }: { - scrollTop: number; - scrollHeight: number; - clientHeight: number; - }) => { - const scrollableHeight = scrollHeight - clientHeight; - if (scrollableHeight <= 0) { - return; - } - - const scrollPercentage = scrollTop / scrollableHeight; - const isScrollingUp = scrollTop < lastScrollTopRef.current; - const isScrollingDown = scrollTop > lastScrollTopRef.current; - - const isAtTop = scrollPercentage < 0.05; - if (onAtTopChange && isAtTop !== wasAtTopRef.current) { - wasAtTopRef.current = isAtTop; - onAtTopChange(isAtTop); - } - - // Near top (newest logs with newest-first) - for running tasks - // Only fire when entering the region (edge detection) - // The region is defined by both scroll direction AND position, so changing - // direction automatically resets the region state - const isInTopRegion = isScrollingUp && scrollPercentage < 0.3; - if (isInTopRegion && !wasInTopRegionRef.current && onScrollToTop) { - onScrollToTop(); - } - wasInTopRegionRef.current = isInTopRegion; - - // Near bottom (older logs with newest-first) - for infinite scroll - // Only fire when entering the region (edge detection) - // The region is defined by both scroll direction AND position, so changing - // direction automatically resets the region state - const isInBottomRegion = isScrollingDown && scrollPercentage > 0.7; - if ( - isInBottomRegion && - !wasInBottomRegionRef.current && - onScrollToBottom - ) { - onScrollToBottom(); - } - wasInBottomRegionRef.current = isInBottomRegion; - - lastScrollTopRef.current = scrollTop; - }, - [onScrollToTop, onScrollToBottom, onAtTopChange], - ); - - return ( -
- ( - { - onScroll(args); - handleScroll(args); - }} - onLineContentClick={handleLineClick} - selectableLines - /> - )} - /> -
- ); -} - -export default Terminal; diff --git a/frontend/app/src/components/v1/cloud/logging/log-search/autocomplete.ts b/frontend/app/src/components/v1/cloud/logging/log-search/autocomplete.ts index 8f5cf78c5f..8e30621207 100644 --- a/frontend/app/src/components/v1/cloud/logging/log-search/autocomplete.ts +++ b/frontend/app/src/components/v1/cloud/logging/log-search/autocomplete.ts @@ -31,11 +31,22 @@ export interface AutocompleteState { export function getAutocomplete( query: string, - availableAttempts?: number[], + availableAttempts: number[], ): AutocompleteState { const trimmed = query.trimEnd(); const lastWord = trimmed.split(' ').pop() || ''; + // Check for trailing space FIRST - indicates user wants to add a new filter + // Don't check this if the query ends with a colon (e.g., "level:") + if (query.endsWith(' ') && !trimmed.endsWith(':')) { + return { mode: 'key', suggestions: FILTER_KEYS }; + } + + // Check for empty input + if (trimmed === '') { + return { mode: 'key', suggestions: FILTER_KEYS }; + } + if (lastWord.startsWith('level:')) { const partial = lastWord.slice(6).toLowerCase(); const suggestions = LOG_LEVELS.filter((level) => @@ -72,10 +83,6 @@ export function getAutocomplete( return { mode: 'key', suggestions: matchingKeys }; } - if (trimmed === '' || query.endsWith(' ')) { - return { mode: 'key', suggestions: FILTER_KEYS }; - } - return { mode: 'none', suggestions: [] }; } diff --git a/frontend/app/src/components/v1/cloud/logging/log-search/log-search-input.tsx b/frontend/app/src/components/v1/cloud/logging/log-search/log-search-input.tsx index 4d8ffe88e9..5c115cbee3 100644 --- a/frontend/app/src/components/v1/cloud/logging/log-search/log-search-input.tsx +++ b/frontend/app/src/components/v1/cloud/logging/log-search/log-search-input.tsx @@ -1,21 +1,7 @@ import { getAutocomplete, applySuggestion } from './autocomplete'; +import type { AutocompleteSuggestion } from './types'; import { useLogsContext } from './use-logs'; -import { Button } from '@/components/v1/ui/button'; -import { - Command, - CommandGroup, - CommandItem, - CommandList, -} from '@/components/v1/ui/command'; -import { Input } from '@/components/v1/ui/input'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/v1/ui/popover'; -import { cn } from '@/lib/utils'; -import { MagnifyingGlassIcon, Cross2Icon } from '@radix-ui/react-icons'; -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { SearchBarWithFilters } from '@/components/v1/molecules/search-bar-with-filters/search-bar-with-filters'; export function LogSearchInput({ placeholder = 'Search logs...', @@ -25,241 +11,25 @@ export function LogSearchInput({ className?: string; }) { const { queryString, setQueryString, availableAttempts } = useLogsContext(); - const inputRef = useRef(null); - const blurTimeoutRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(); - const [localValue, setLocalValue] = useState(queryString); - - useEffect(() => { - setLocalValue(queryString); - }, [queryString]); - - const { suggestions } = getAutocomplete(localValue, availableAttempts); - - const submitSearch = useCallback(() => { - setQueryString(localValue); - }, [localValue, setQueryString]); - - const handleFilterChipClick = useCallback( - (filterKey: string) => { - const newValue = localValue ? `${localValue} ${filterKey}` : filterKey; - setLocalValue(newValue); - setIsOpen(true); - setSelectedIndex(undefined); - setTimeout(() => { - const input = inputRef.current; - if (input) { - input.focus(); - input.setSelectionRange(newValue.length, newValue.length); - } - }, 0); - }, - [localValue], - ); - - const handleSelect = useCallback( - (index: number) => { - const suggestion = suggestions[index]; - if (suggestion) { - const newValue = applySuggestion(localValue, suggestion); - setLocalValue(newValue); - if (suggestion.type === 'value') { - setQueryString(newValue); - setIsOpen(false); - } - setSelectedIndex(undefined); - setTimeout(() => { - const input = inputRef.current; - if (input) { - input.focus(); - input.setSelectionRange(newValue.length, newValue.length); - } - }, 0); - } - }, - [localValue, suggestions, setQueryString], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (isOpen && suggestions.length > 0 && selectedIndex !== undefined) { - handleSelect(selectedIndex); - } else { - submitSearch(); - setIsOpen(false); - } - return; - } - - if (!isOpen || suggestions.length === 0) { - return; - } - - if (e.key === 'Escape') { - setIsOpen(false); - setSelectedIndex(undefined); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((i) => { - if (i === undefined) { - return 0; - } - return i < suggestions.length - 1 ? i + 1 : 0; - }); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((i) => { - if (i === undefined) { - return suggestions.length - 1; - } - return i > 0 ? i - 1 : suggestions.length - 1; - }); - } else if (e.key === 'Tab') { - if (selectedIndex !== undefined) { - e.preventDefault(); - handleSelect(selectedIndex); - } - } - }, - [isOpen, suggestions.length, selectedIndex, handleSelect, submitSearch], - ); return ( -
- - -
- - { - setLocalValue(e.target.value); - setIsOpen(true); - setSelectedIndex(undefined); - }} - onKeyDown={handleKeyDown} - onFocus={() => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = null; - } - setIsOpen(true); - setSelectedIndex(undefined); - }} - onBlur={() => { - blurTimeoutRef.current = setTimeout(() => { - setIsOpen(false); - blurTimeoutRef.current = null; - }, 200); - }} - placeholder={placeholder} - className="pl-9 pr-8" - /> - {localValue && ( - - )} -
-
- e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - {suggestions.length > 0 && ( - - - - {suggestions.map((suggestion, index) => ( - handleSelect(index)} - className={cn( - 'flex items-center justify-between', - selectedIndex !== undefined && - index === selectedIndex && - 'bg-accent text-accent-foreground', - )} - onMouseEnter={() => setSelectedIndex(index)} - > -
- {suggestion.color && ( -
- )} - {suggestion.type === 'key' ? ( - - {suggestion.label}: - - ) : ( - - {suggestion.label} - - )} -
- {suggestion.description && ( - - {suggestion.description} - - )} - - ))} - - - - )} -
0 && 'border-t', - )} - > - Available filters: - - -
- - -
+ + value={queryString} + onChange={setQueryString} + onSubmit={setQueryString} + getAutocomplete={getAutocomplete} + applySuggestion={applySuggestion} + autocompleteContext={availableAttempts} + placeholder={placeholder} + className={className} + filterChips={[ + { key: 'level:', label: 'Level', description: 'Filter by log level' }, + { + key: 'attempt:', + label: 'Attempt', + description: 'Filter by attempt number', + }, + ]} + /> ); } diff --git a/frontend/app/src/components/v1/cloud/logging/log-search/types.ts b/frontend/app/src/components/v1/cloud/logging/log-search/types.ts index 6d86f7da2b..5de49a2ec6 100644 --- a/frontend/app/src/components/v1/cloud/logging/log-search/types.ts +++ b/frontend/app/src/components/v1/cloud/logging/log-search/types.ts @@ -1,3 +1,5 @@ +import type { SearchSuggestion } from '@/components/v1/molecules/search-bar-with-filters/search-bar-with-filters'; + export const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; @@ -17,7 +19,8 @@ export interface ParsedLogQuery { errors: string[]; } -export interface AutocompleteSuggestion { +export interface AutocompleteSuggestion + extends SearchSuggestion<'key' | 'value'> { type: 'key' | 'value'; label: string; value: string; diff --git a/frontend/app/src/components/v1/cloud/logging/log-viewer.tsx b/frontend/app/src/components/v1/cloud/logging/log-viewer.tsx index 6934c89da8..fa5043b021 100644 --- a/frontend/app/src/components/v1/cloud/logging/log-viewer.tsx +++ b/frontend/app/src/components/v1/cloud/logging/log-viewer.tsx @@ -1,65 +1,48 @@ -import Terminal from './components/Terminal'; +import { AnsiLine } from './ansi-line'; import { LogLine } from './log-search/use-logs'; +import RelativeDate from '@/components/v1/molecules/relative-date'; import { V1TaskStatus } from '@/lib/api'; -import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { useMemo, useCallback, useRef, useState } from 'react'; const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, }; -// ANSI color codes -const WHITE = '\x1b[37m'; // Regular white for timestamps (dimmer than bright white) -const RESET = '\x1b[0m'; // Reset to default - -// Nice theme colors for instance names (using bright ANSI colors) -const INSTANCE_COLORS = [ - '\x1b[94m', // Bright blue - '\x1b[96m', // Bright cyan - '\x1b[95m', // Bright magenta - '\x1b[36m', // Cyan - '\x1b[34m', // Blue - '\x1b[35m', // Magenta -]; - -const hashString = (str: string): number => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash); -}; +const LEVEL_STYLES: Record = + { + error: { + bg: 'bg-red-500/10', + text: 'text-red-600 dark:text-red-400', + dot: 'bg-red-500', + }, + warn: { + bg: 'bg-yellow-500/10', + text: 'text-yellow-600 dark:text-yellow-400', + dot: 'bg-yellow-500', + }, + info: { + bg: 'bg-green-500/10', + text: 'text-green-600 dark:text-green-400', + dot: 'bg-green-500', + }, + debug: { + bg: 'bg-gray-500/10', + text: 'text-gray-500 dark:text-gray-400', + dot: 'bg-gray-500', + }, + }; -const getInstanceColor = (instance: string): string => { - const hash = hashString(instance); - return INSTANCE_COLORS[hash % INSTANCE_COLORS.length]; -}; - -const formatLogLine = (log: LogLine): string => { - let line = ''; - - if (log.timestamp) { - const formattedTime = new Date(log.timestamp) - .toLocaleString('sv', DATE_FORMAT_OPTIONS) - .replace(',', '.') - .replace(' ', 'T'); - line += `${WHITE}${formattedTime}${RESET} `; - } - - if (log.instance) { - const color = getInstanceColor(log.instance); - line += `${color}[${log.instance}]${RESET} `; - } - - line += log.line || ''; - - return line; +const formatTimestamp = (timestamp: string): string => { + return new Date(timestamp) + .toLocaleString('sv', DATE_FORMAT_OPTIONS) + .replace(',', '.'); }; export interface LogViewerProps { @@ -87,6 +70,28 @@ function getEmptyStateMessage(taskStatus?: V1TaskStatus): string { } } +const LevelBadge = ({ level }: { level: string }) => { + const normalized = level.toLowerCase(); + const style = LEVEL_STYLES[normalized] ?? LEVEL_STYLES.debug; + + return ( + + + {normalized} + + ); +}; + +function isNonEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined; +} + export function LogViewer({ logs, onScrollToBottom, @@ -95,27 +100,97 @@ export function LogViewer({ isLoading, taskStatus, }: LogViewerProps) { - const formattedLogs = useMemo(() => { + const [showRelativeTime, setShowRelativeTime] = useState(true); + const scrollRef = useRef(null); + const lastScrollTopRef = useRef(0); + const wasAtTopRef = useRef(true); + const wasInTopRegionRef = useRef(false); + const wasInBottomRegionRef = useRef(false); + const [selectedLogIndex, setSelectedLogIndex] = useState(); + + const sortedLogs = useMemo(() => { if (logs.length === 0) { - return ''; + return []; } - const sortedLogs = [...logs].sort((a, b) => { + return [...logs].sort((a, b) => { if (!a.timestamp || !b.timestamp) { return 0; } - // descending return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); }); - - return sortedLogs.map(formatLogLine).join('\n'); }, [logs]); + const { hasInstance, hasAttempt } = useMemo(() => { + let hasInstance = false; + let hasAttempt = false; + for (const log of sortedLogs) { + if (log.instance) { + hasInstance = true; + } + if (log.attempt !== undefined) { + hasAttempt = true; + } + if (hasInstance && hasAttempt) { + break; + } + } + return { hasInstance, hasAttempt }; + }, [sortedLogs]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = el; + const scrollableHeight = scrollHeight - clientHeight; + if (scrollableHeight <= 0) { + return; + } + + const scrollPercentage = scrollTop / scrollableHeight; + const isScrollingUp = scrollTop < lastScrollTopRef.current; + const isScrollingDown = scrollTop > lastScrollTopRef.current; + + const isAtTop = scrollPercentage < 0.05; + if (onAtTopChange && isAtTop !== wasAtTopRef.current) { + wasAtTopRef.current = isAtTop; + onAtTopChange(isAtTop); + } + + const isInTopRegion = isScrollingUp && scrollPercentage < 0.3; + if (isInTopRegion && !wasInTopRegionRef.current && onScrollToTop) { + onScrollToTop(); + } + wasInTopRegionRef.current = isInTopRegion; + + const isInBottomRegion = isScrollingDown && scrollPercentage > 0.7; + if (isInBottomRegion && !wasInBottomRegionRef.current && onScrollToBottom) { + onScrollToBottom(); + } + wasInBottomRegionRef.current = isInBottomRegion; + + lastScrollTopRef.current = scrollTop; + }, [onScrollToTop, onScrollToBottom, onAtTopChange]); + const isRunning = taskStatus === V1TaskStatus.RUNNING; + // Build dynamic grid-template-columns + const gridCols = [ + 'auto', // timestamp + '72px', // level + hasInstance && 'minmax(100px, 200px)', + hasAttempt && 'auto', + 'minmax(0, 1fr)', // message + ] + .filter(Boolean) + .join(' '); + if (isLoading) { return ( -
+
Loading logs...
); @@ -124,7 +199,7 @@ export function LogViewer({ const isEmpty = logs.length === 0; if (isEmpty && taskStatus !== undefined) { return ( -
+
{getEmptyStateMessage(taskStatus)} @@ -132,24 +207,119 @@ export function LogViewer({ ); } + // Column count for subgrid span + const colCount = 3 + (hasInstance ? 1 : 0) + (hasAttempt ? 1 : 0); + return ( -
+
{isRunning && ( -
+
- - + + Live
)} - + +
+ {/* Header row */} +
+
setShowRelativeTime((prev) => !prev)} + > + Timestamp +
+
+ Level +
+ {hasInstance && ( +
+ Instance +
+ )} + {hasAttempt && ( +
+ Attempt +
+ )} +
+ Message +
+
+ + {/* Data rows */} + {sortedLogs + .filter((log) => isNonEmpty(log.line)) + .map((log, ix) => ( +
+
+ {log.timestamp ? ( + showRelativeTime ? ( + + ) : ( + formatTimestamp(log.timestamp) + ) + ) : ( + '—' + )} +
+
+ {log.level ? ( + + ) : ( + + )} +
+ {hasInstance && ( +
+ {log.instance || '—'} +
+ )} + {hasAttempt && ( +
+ {log.attempt ?? '—'} +
+ )} +
{ + setSelectedLogIndex((prev) => (prev === ix ? undefined : ix)); + }} + onMouseEnter={(e) => { + const el = e.currentTarget; + if (el.scrollWidth > el.clientWidth) { + el.style.cursor = 'pointer'; + } else { + el.style.cursor = 'default'; + } + }} + > + {/* fixme: figure out how to use the type guard properly here */} + +
+
+ ))} +
); } diff --git a/frontend/app/src/components/v1/molecules/search-bar-with-filters/README.md b/frontend/app/src/components/v1/molecules/search-bar-with-filters/README.md new file mode 100644 index 0000000000..bb902d7245 --- /dev/null +++ b/frontend/app/src/components/v1/molecules/search-bar-with-filters/README.md @@ -0,0 +1,306 @@ +# Filter Examples + +## Time Range Filter + +For date/time picker inputs: + +```tsx +import { CustomFilterInputProps } from './search-bar-with-filters'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +function TimeRangeFilterInput({ + filterKey: _filterKey, + currentValue, + onComplete, + onCancel, +}: CustomFilterInputProps) { + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + + return ( +
+
Select Time Range
+ setStart(e.target.value)} + /> + setEnd(e.target.value)} + /> +
+ + +
+
+ ); +} + +// Complete usage with SearchBarWithFilters: +function MySearchComponent() { + const [query, setQuery] = useState(''); + + const getAutocomplete = (q: string) => { + const lastWord = q.split(' ').pop() || ''; + + // Show time range picker when "time:" is typed + if (lastWord.startsWith('time:')) { + return { + suggestions: [ + { + type: 'custom-input', + label: 'Select time range', + value: 'time:', + customInput: TimeRangeFilterInput, + }, + ], + }; + } + + // Return other suggestions... + return { suggestions: [] }; + }; + + const applySuggestion = (q: string, suggestion: any) => { + // Apply the time range value + return q + suggestion.value; + }; + + return ( + + ); +} +``` + +## Multi-Select Filter + +For selecting multiple values: + +```tsx +import { Checkbox } from '@/components/ui/checkbox'; + +function MultiSelectFilterInput({ + currentValue, + onComplete, + onCancel, +}: CustomFilterInputProps) { + const options = [ + { value: 'active', label: 'Active', color: 'bg-green-500' }, + { value: 'pending', label: 'Pending', color: 'bg-yellow-500' }, + { value: 'failed', label: 'Failed', color: 'bg-red-500' }, + ]; + + const [selected, setSelected] = useState( + currentValue ? currentValue.split(',') : [], + ); + + return ( +
+
Select Statuses
+
+ {options.map((option) => ( +
+ { + setSelected((prev) => + prev.includes(option.value) + ? prev.filter((v) => v !== option.value) + : [...prev, option.value], + ); + }} + /> +
+ {option.label} +
+ ))} +
+
+ + +
+
+ ); +} + +// Complete usage with SearchBarWithFilters: +function MySearchComponent() { + const [query, setQuery] = useState(''); + + const getAutocomplete = (q: string) => { + const lastWord = q.split(' ').pop() || ''; + + // Show multi-select when "status:" is typed + if (lastWord.startsWith('status:')) { + return { + suggestions: [ + { + type: 'multi-select', + label: 'Select statuses', + value: 'status:', + customInput: MultiSelectFilterInput, + }, + ], + }; + } + + // Return other suggestions... + return { suggestions: [] }; + }; + + const applySuggestion = (q: string, suggestion: any) => { + // Apply the selected statuses + return q + suggestion.value; + }; + + return ( + + ); +} +``` + +## Numeric Range Filter + +For port ranges, counts, etc: + +```tsx +function RangeFilterInput({ + filterKey, + currentValue, + onComplete, + onCancel, +}: CustomFilterInputProps) { + const [min, setMin] = useState(0); + const [max, setMax] = useState(0); + + return ( +
+
+ Select {filterKey.replace(':', '')} Range +
+
+ setMin(Number(e.target.value))} + placeholder="Min" + className="flex-1 px-3 py-2 text-sm border rounded-md" + /> + to + setMax(Number(e.target.value))} + placeholder="Max" + className="flex-1 px-3 py-2 text-sm border rounded-md" + /> +
+
+ + +
+
+ ); +} + +// Complete usage with SearchBarWithFilters: +function MySearchComponent() { + const [query, setQuery] = useState(''); + + const getAutocomplete = (q: string) => { + const lastWord = q.split(' ').pop() || ''; + + // Show range input when "port:" is typed + if (lastWord.startsWith('port:')) { + return { + suggestions: [ + { + type: 'range', + label: 'Enter port range', + value: 'port:', + customInput: RangeFilterInput, + }, + ], + }; + } + + // Return other suggestions... + return { suggestions: [] }; + }; + + const applySuggestion = (q: string, suggestion: any) => { + // Apply the port range + return q + suggestion.value; + }; + + return ( + + ); +} +``` diff --git a/frontend/app/src/components/v1/molecules/search-bar-with-filters/search-bar-with-filters.tsx b/frontend/app/src/components/v1/molecules/search-bar-with-filters/search-bar-with-filters.tsx new file mode 100644 index 0000000000..a17600a44d --- /dev/null +++ b/frontend/app/src/components/v1/molecules/search-bar-with-filters/search-bar-with-filters.tsx @@ -0,0 +1,548 @@ +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from '@/components/v1/ui/command'; +import { Input } from '@/components/v1/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/v1/ui/popover'; +import { cn } from '@/lib/utils'; +import { MagnifyingGlassIcon, Cross2Icon } from '@radix-ui/react-icons'; +import React, { + useState, + useRef, + useCallback, + useEffect, + useMemo, +} from 'react'; + +export interface SearchSuggestion { + type: TType; + label: string; + value: string; + description?: string; + color?: string; + metadata?: Record; +} + +export interface AutocompleteResult { + suggestions: TSuggestion[]; + mode?: string; +} + +export interface FilterChip { + key: string; + label: string; + description?: string; + // For complex filters that need custom input components + customInput?: React.ComponentType; + // For filters that need multi-step input (e.g., time ranges) + requiresCustomInput?: boolean; +} + +export interface CustomFilterInputProps { + filterKey: string; + currentValue: string; + onComplete: (value: string) => void; + onCancel: () => void; +} + +export interface SearchBarWithFiltersProps< + TSuggestion extends SearchSuggestion, + TContext, +> { + // Value control + value: string; + /** + * Called when a complete filter value is selected from the dropdown, or when + * the input is cleared. NOT called on every keystroke — the component buffers + * input locally and only notifies the parent on meaningful state changes. + * + * Also serves as the fallback for Enter when `onSubmit` is not provided. + */ + onChange: (value: string) => void; + /** + * Called when Enter is pressed while the dropdown is closed or has no + * suggestions (i.e. the user wants to search with the current free-text + * value as-is). When the dropdown is open, Enter autocompletes instead. + * + * If omitted, `onChange` is used as a fallback. + */ + onSubmit?: (value: string) => void; + + // Autocomplete functions (domain-specific) + getAutocomplete: ( + query: string, + context: TContext, + ) => AutocompleteResult; + applySuggestion: (query: string, suggestion: TSuggestion) => string; + autocompleteContext: TContext; + + // Custom rendering + renderSuggestion?: ( + suggestion: TSuggestion, + isSelected: boolean, + ) => React.ReactNode; + + // Filter chips + filterChips?: FilterChip[]; + onFilterChipClick?: (filterKey: string) => void; + + // UI customization + placeholder?: string; + className?: string; + searchIcon?: React.ReactNode; + clearIcon?: React.ReactNode; + popoverClassName?: string; +} + +// ============================================================================ +// Filter highlight helpers +// ============================================================================ + +/** Colour for the filter key prefix (e.g. "level" in level:error) */ +const FILTER_KEY_COLOR = 'hsl(var(--brand))'; +/** Colour for the filter value suffix (e.g. "error" in level:error) */ +const FILTER_VALUE_COLOR = + 'color-mix(in srgb, hsl(var(--brand)) 70%, hsl(var(--foreground)))'; + +interface TextSegment { + text: string; + isFilter: boolean; +} + +/** + * Splits the input value into segments, tagging recognised filter tokens + * (e.g. `level:error`) so they can be rendered with custom colours. + */ +const parseFilterSegments = ( + value: string, + filterChips: FilterChip[], +): TextSegment[] => { + if (!value) { + return []; + } + + const segments: TextSegment[] = []; + // Split on whitespace runs while preserving them + const parts = value.split(/(\s+)/); + + for (const part of parts) { + if (/^\s+$/.test(part)) { + segments.push({ text: part, isFilter: false }); + continue; + } + + const isFilter = filterChips.some((chip) => + part.toLowerCase().startsWith(chip.key.toLowerCase()), + ); + + segments.push({ text: part, isFilter }); + } + + return segments; +}; + +// ============================================================================ +// Component +// ============================================================================ + +export function SearchBarWithFilters< + TSuggestion extends SearchSuggestion, + TContext, +>({ + value, + onChange, + onSubmit, + getAutocomplete, + applySuggestion, + autocompleteContext, + renderSuggestion, + filterChips, + // onFilterChipClick, + placeholder = 'Search...', + className, + searchIcon, + clearIcon, + popoverClassName, +}: SearchBarWithFiltersProps) { + const inputRef = useRef(null); + const blurTimeoutRef = useRef(null); + const justSelectedRef = useRef(false); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(); + const [localValue, setLocalValue] = useState(value); + const prevSuggestionsRef = useRef([]); + const prevLocalValueRef = useRef(value); + const overlayInnerRef = useRef(null); + + const hasColoredFilters = !!filterChips?.length; + + useEffect(() => { + setLocalValue(value); + }, [value]); + + // Clear any pending blur timeout when the component unmounts + useEffect(() => { + return () => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } + }; + }, []); + + const segments = useMemo( + () => + hasColoredFilters ? parseFilterSegments(localValue, filterChips) : [], + [localValue, filterChips, hasColoredFilters], + ); + + // Keep the highlight overlay horizontally in sync with the native input scroll + useEffect(() => { + const input = inputRef.current; + if (!input || !hasColoredFilters) { + return; + } + + const syncScroll = () => { + if (overlayInnerRef.current) { + overlayInnerRef.current.style.transform = `translateX(-${input.scrollLeft}px)`; + } + }; + + input.addEventListener('scroll', syncScroll); + syncScroll(); + + return () => input.removeEventListener('scroll', syncScroll); + }, [localValue, hasColoredFilters]); + + const { suggestions } = useMemo( + () => getAutocomplete(localValue, autocompleteContext), + [getAutocomplete, localValue, autocompleteContext], + ); + + // Reset selection when suggestions change (e.g., from keys to values after selecting a key) + useEffect(() => { + const suggestionsChanged = + prevSuggestionsRef.current.length !== suggestions.length || + prevSuggestionsRef.current.some( + (prev, i) => prev.value !== suggestions[i]?.value, + ); + + if (suggestionsChanged) { + // Reset selection - user must explicitly navigate with arrows + setSelectedIndex(undefined); + prevSuggestionsRef.current = suggestions; + } + }, [suggestions]); + + // Open dropdown when space is added (for adding new filters) + useEffect(() => { + const justAddedSpace = + localValue.endsWith(' ') && + !prevLocalValueRef.current.endsWith(' ') && + localValue.length > prevLocalValueRef.current.length; + + if (justAddedSpace && suggestions.length > 0) { + setIsOpen(true); + } + + prevLocalValueRef.current = localValue; + }, [localValue, suggestions.length]); + + const submitSearch = useCallback(() => { + if (onSubmit) { + onSubmit(localValue); + } else { + onChange(localValue); + } + }, [localValue, onChange, onSubmit]); + + const handleSelect = useCallback( + (index: number) => { + const suggestion = suggestions[index]; + if (suggestion) { + const newValue = applySuggestion(localValue, suggestion); + setLocalValue(newValue); + + // Only update parent state for 'value' type suggestions (complete filters) + // This triggers the actual search + if (suggestion.type === 'value') { + onChange(newValue); + setIsOpen(false); + // Mark that we just selected a value to prevent reopening on refocus + justSelectedRef.current = true; + } + // For 'key' type suggestions, don't update parent - user is still building the filter + // Keep dropdown open for value selection + + setTimeout(() => { + const input = inputRef.current; + if (input) { + input.focus(); + input.setSelectionRange(newValue.length, newValue.length); + } + // Reset the flag after focus is restored + setTimeout(() => { + justSelectedRef.current = false; + }, 50); + }, 0); + } + }, + [localValue, suggestions, applySuggestion, onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + // If the dropdown is open with suggestions, Enter autocompletes (like Tab) + // Otherwise, Enter submits the search + if (isOpen && suggestions.length > 0) { + const indexToApply = selectedIndex !== undefined ? selectedIndex : 0; + if (suggestions[indexToApply]) { + handleSelect(indexToApply); + } + } else { + submitSearch(); + setIsOpen(false); + } + return; + } + + // Don't handle space specially - let onChange detect it and handle opening + // This ensures suggestions have updated before opening dropdown + + if (!isOpen || suggestions.length === 0) { + return; + } + + if (e.key === 'Escape') { + setIsOpen(false); + setSelectedIndex(undefined); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((i) => { + if (i === undefined) { + return 0; + } + return i < suggestions.length - 1 ? i + 1 : 0; + }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => { + if (i === undefined) { + return suggestions.length - 1; + } + return i > 0 ? i - 1 : suggestions.length - 1; + }); + } else if (e.key === 'Tab') { + e.preventDefault(); + // Tab autocompletes - apply the selected suggestion or first one if none selected + const indexToApply = selectedIndex !== undefined ? selectedIndex : 0; + if (suggestions[indexToApply]) { + handleSelect(indexToApply); + } + } + }, + [isOpen, suggestions, selectedIndex, handleSelect, submitSearch], + ); + + const defaultRenderSuggestion = useCallback( + (suggestion: TSuggestion, isSelected: boolean) => ( +
+
+ {suggestion.color && ( +
+ )} + {suggestion.type === 'key' ? ( + + {suggestion.label}: + + ) : ( + {suggestion.label} + )} +
+ {suggestion.description && ( + + {suggestion.description} + + )} +
+ ), + [], + ); + + const renderSuggestionContent = renderSuggestion || defaultRenderSuggestion; + + const handleClear = useCallback(() => { + setLocalValue(''); + // Clear the search by notifying parent with empty string + onChange(''); + inputRef.current?.focus(); + setIsOpen(false); + }, [onChange]); + + return ( +
+ 0} modal={false}> + +
+ {searchIcon || ( + + )} + { + // Only update local state for autocomplete + // Don't trigger parent onChange until Enter is pressed or value suggestion is selected + const newValue = e.target.value; + setLocalValue(newValue); + // Open dropdown when user types (if there are suggestions) + if (newValue.length > 0) { + setIsOpen(true); + } + }} + onKeyDown={handleKeyDown} + onFocus={() => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = null; + } + // Open dropdown on focus to show available filters + // But not if we just selected a value (prevents reopening after click) + if (!justSelectedRef.current) { + setIsOpen(true); + } + }} + onBlur={() => { + blurTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + blurTimeoutRef.current = null; + }, 200); + }} + placeholder={placeholder} + className={cn( + 'pl-9 pr-8', + hasColoredFilters && 'text-transparent', + )} + style={ + hasColoredFilters + ? { caretColor: 'hsl(var(--foreground))' } + : undefined + } + data-cy="search-bar-input" + /> + {/* Coloured highlight overlay – mirrors input text with filter colours */} + {hasColoredFilters && localValue && ( + + )} + {localValue && ( + + )} +
+
+ e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {suggestions.length > 0 && ( + s.value).join(',')} + value={ + selectedIndex !== undefined + ? suggestions[selectedIndex]?.value + : undefined + } + > + + + {suggestions.map((suggestion, index) => ( + handleSelect(index)} + className={cn( + 'flex items-center justify-between aria-selected:bg-primary/5 text-primary/60', + selectedIndex !== undefined && + index === selectedIndex && + 'text-accent-foreground', + )} + onMouseEnter={() => setSelectedIndex(index)} + data-cy={`search-bar-suggestion-${index}`} + > + {renderSuggestionContent( + suggestion, + selectedIndex === index, + )} + + ))} + + + + )} + +
+
+ ); +} diff --git a/frontend/app/src/index.css b/frontend/app/src/index.css index 1409c09998..1642a5ee2c 100644 --- a/frontend/app/src/index.css +++ b/frontend/app/src/index.css @@ -44,6 +44,12 @@ --terminal-bg: #f8fafc; --terminal-fg: #1e293b; + --terminal-red: #dc2626; + --terminal-green: #16a34a; + --terminal-yellow: #ca8a04; + --terminal-blue: #2563eb; + --terminal-magenta: #c026d3; + --terminal-cyan: #0891b2; } .dark { @@ -83,6 +89,12 @@ --terminal-bg: #1e293b; --terminal-fg: #e2e8f0; + --terminal-red: #f87171; + --terminal-green: #4ade80; + --terminal-yellow: #facc15; + --terminal-blue: #60a5fa; + --terminal-magenta: #e879f9; + --terminal-cyan: #22d3ee; } } diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/task-run-logs.tsx b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/task-run-logs.tsx index 7f80bee13b..1e339f9ba6 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/task-run-logs.tsx +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/task-run-logs.tsx @@ -5,9 +5,6 @@ import { } from '@/components/v1/cloud/logging/log-search/use-logs'; import { LogViewer } from '@/components/v1/cloud/logging/log-viewer'; import { V1TaskSummary } from '@/lib/api'; -import useCloud from '@/pages/auth/hooks/use-cloud'; -import { appRoutes } from '@/router'; -import { useParams } from '@tanstack/react-router'; export function TaskRunLogs({ taskRun, @@ -24,7 +21,6 @@ export function TaskRunLogs({ } function TaskRunLogsContent() { - const { tenant: tenantId } = useParams({ from: appRoutes.tenantRoute.to }); const { logs, fetchOlderLogs, @@ -33,8 +29,7 @@ function TaskRunLogsContent() { isLoading, taskStatus, } = useLogsContext(); - const { featureFlags } = useCloud(tenantId); - const isLogSearchEnabled = featureFlags?.enable_log_search === 'true'; + const isLogSearchEnabled = true; return (
diff --git a/frontend/docs/components/DurableWorkflowComparisonDiagram.tsx b/frontend/docs/components/DurableWorkflowComparisonDiagram.tsx new file mode 100644 index 0000000000..aacf694fca --- /dev/null +++ b/frontend/docs/components/DurableWorkflowComparisonDiagram.tsx @@ -0,0 +1,766 @@ +import React from "react"; + +const DurableWorkflowComparisonDiagram: React.FC = () => { + const nodeW = 130; + const nodeH = 36; + const smallW = 100; + const smallH = 30; + const rx = 8; + + return ( +
+
+
+ {/* Left: Durable Task Execution */} +
+ + + + + + + + + + + + + + + + + + + + Durable Task + + + shape of work is dynamic + + + {/* Container */} + + + {/* Step 1: Do work */} + + + do_work() + + + line 12 + + + {/* Arrow 1→2 */} + + + {/* Step 2: sleep_for (checkpoint) */} + + + sleep_for(24h) + + + checkpoint + + {/* Save icon */} + + + + + + + {/* Arrow 2→3 */} + + + {/* Step 3: spawn_tasks */} + + + spawn_tasks() + + + fan-out + + + {/* Spawn arrows to child tasks */} + + + + {/* Child task A */} + + + child task 1 + + + {/* Child task B */} + + + child task 2 + + + {/* "..." more children */} + + ... + + + {/* Arrow 3→4 */} + + + {/* Step 4: collect results (checkpoint) */} + + + wait_for_results() + + + checkpoint + + {/* Save icon */} + + + + + + + {/* Arrow 4→5 */} + + + {/* Step 5: process results */} + + + process_results() + + + line 20 + + + {/* Left annotation: call stack bracket */} + + + + + single function + + + {/* Annotation: children run independently */} + + run on any worker + + + {/* Bottom annotation */} + + procedural · checkpoints · N decided at runtime + + +
+ + {/* Right: DAG */} +
+ + + + + + + + + + + + + + + + + + + + DAG Workflow + + + shape of work is known upfront + + + {/* Container */} + + + {/* Task A (top) */} + + + Extract + + + {/* Fan out: A → B and A → C */} + + + + {/* Task B (left) */} + + + Transform A + + + {/* Task C (right) */} + + + Transform B + + + {/* Fan in: B → D and C → D */} + + + + {/* Task D (bottom, merge) */} + + + Load + + + {/* Annotations */} + + start + + + parallel + + + waits for both + + + {/* Bottom annotation */} + + declared graph · fixed shape · each task independent + + +
+
+
+
+ ); +}; + +export default DurableWorkflowComparisonDiagram; diff --git a/frontend/docs/components/Search.tsx b/frontend/docs/components/Search.tsx index abfc095227..0267555d52 100644 --- a/frontend/docs/components/Search.tsx +++ b/frontend/docs/components/Search.tsx @@ -17,13 +17,17 @@ import { } from "@/lib/search-config"; // --------------------------------------------------------------------------- -// Lazy singleton for the search index +// Lazy singleton for the search index (keyed by basePath so basePath changes don't reuse wrong index) // --------------------------------------------------------------------------- let indexPromise: Promise | null = null; - -function loadIndex(): Promise { - if (!indexPromise) { - indexPromise = fetch("/llms-search-index.json") +let indexPromiseBasePath: string | undefined = undefined; + +function loadIndex(basePath: string = ""): Promise { + const prefix = basePath ? basePath.replace(/\/$/, "") : ""; + const url = `${prefix}/llms-search-index.json`; + if (indexPromise === null || indexPromiseBasePath !== basePath) { + indexPromiseBasePath = basePath; + indexPromise = fetch(url) .then((res) => { if (!res.ok) throw new Error(`Failed to load search index: ${res.status}`); @@ -222,6 +226,8 @@ export default function Search({ className }: { className?: string }) { prevIsOpenRef.current = isOpen; }, [isOpen]); + const basePath = router.basePath ?? ""; + // Lazy-load the search index on first interaction (focus / open) rather // than on every page load. The search-query effect below already handles // the case where the index isn't ready yet, so this is purely a preload @@ -230,9 +236,9 @@ export default function Search({ className }: { className?: string }) { const preloadIndex = useCallback(() => { if (!preloadTriggered.current) { preloadTriggered.current = true; - loadIndex().then(() => setIndexReady(true)); + loadIndex(basePath).then(() => setIndexReady(true)); } - }, []); + }, [basePath]); // Run the search when the query changes useEffect(() => { @@ -258,7 +264,7 @@ export default function Search({ className }: { className?: string }) { if (!indexReady) { setLoading(true); - loadIndex() + loadIndex(basePath) .then((idx) => { setIndexReady(true); setLoading(false); @@ -268,10 +274,10 @@ export default function Search({ className }: { className?: string }) { return; } - loadIndex() + loadIndex(basePath) .then(runSearch) .catch(() => {}); - }, [query, indexReady]); + }, [query, indexReady, basePath]); // Global keyboard shortcut: / or Cmd/Ctrl+K useEffect(() => { diff --git a/frontend/docs/components/SidebarFolderNav.tsx b/frontend/docs/components/SidebarFolderNav.tsx new file mode 100644 index 0000000000..2ca5b3736e --- /dev/null +++ b/frontend/docs/components/SidebarFolderNav.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +/** + * Nextra renders sidebar folders with index pages as buttons. When a collapsed + * folder is clicked, we navigate to its index (data-href) so the user lands + * on the overview; the sidebar then opens because the route is inside the folder. + * Works for any folder that has a route (e.g. has an index.mdx). + */ +export function SidebarFolderNav() { + const router = useRouter(); + + useEffect(() => { + function handleClick(e: MouseEvent) { + const target = e.target as HTMLElement; + const button = target.closest?.( + ".nextra-sidebar-container button[data-href]", + ) as HTMLButtonElement | null; + if (!button) return; + + const href = button.getAttribute("data-href"); + if (!href) return; + + const li = button.closest?.("li"); + const isOpen = li?.classList.contains("open"); + if (isOpen) return; + + e.preventDefault(); + e.stopPropagation(); + router.push(href); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [router]); + + return null; +} diff --git a/frontend/docs/next.config.mjs b/frontend/docs/next.config.mjs index 559e6e6118..63a0d42d6a 100644 --- a/frontend/docs/next.config.mjs +++ b/frontend/docs/next.config.mjs @@ -13,6 +13,7 @@ const withNextra = nextra({ /** @type {import('next').NextConfig} */ const nextConfig = { + basePath: '/v1', transpilePackages: ["react-tweet"], swcMinify: false, images: { @@ -20,197 +21,603 @@ const nextConfig = { }, async redirects() { return [ + // Root and app root → main essentials page (never /home) { - source: '/compute', - destination: '/home/compute', + source: '/', + destination: '/v1/essentials', + permanent: false, + basePath: false, + }, + { + source: '/v1', + destination: '/v1/essentials/', + permanent: false, + basePath: false, + }, + { + source: '/v1/', + destination: '/v1/essentials', + permanent: false, + basePath: false, + }, + { + source: '/essentials', + destination: '/v1/essentials', + permanent: false, + basePath: false, + }, + { + source: '/essentials/setup', + destination: '/v1/setup/advanced', permanent: true, + basePath: false, }, { - source: '/compute/:path', - destination: '/home/compute', + source: '/essentials/environments', + destination: '/v1/setup/advanced/environments', permanent: true, + basePath: false, }, { - source: '/:path((?!api|home|cli|v1|v0|compute|sdk|contributing|self-hosting|launches|blog|llms|favicon\\.ico|.*\\.png|.*\\.gif|.*\\.svg|_next/.*|monitoring\-demo\.mp4).*)', - destination: '/home/:path*', - permanent: false, + source: '/v1/essentials/hatchet-cloud-quickstart', + destination: '/v1/setup/quickstart', + permanent: true, + basePath: false, + }, + { + source: '/v1/essentials/hatchet-cloud-quickstart/advanced/setup', + destination: '/v1/setup/advanced', + permanent: true, + basePath: false, + }, + { + source: '/essentials/hatchet-cloud-quickstart/advanced/setup', + destination: '/v1/setup/advanced', + permanent: true, + basePath: false, + }, + { + source: '/essentials/hatchet-cloud-quickstart/setup', + destination: '/v1/setup/advanced', + permanent: true, + basePath: false, + }, + { + source: '/essentials/hatchet-cloud-quickstart/environments', + destination: '/v1/setup/advanced/environments', + permanent: true, + basePath: false, + }, + { + source: '/v1/essentials/hatchet-cloud-quickstart/advanced', + destination: '/v1/setup/advanced', + permanent: true, + basePath: false, + }, + { + source: '/v1/essentials/hatchet-cloud-quickstart/advanced/environments', + destination: '/v1/setup/advanced/environments', + permanent: true, + basePath: false, + }, + { + source: '/v1/essentials/hatchet-cloud-quickstart/coding-agents', + destination: '/v1/setup/using-coding-agents', + permanent: true, + basePath: false, + }, + { + source: '/compute', + destination: '/v1/essentials/compute', + permanent: true, + basePath: false, + }, + { + source: '/compute/:path', + destination: '/v1/essentials/compute', + permanent: true, + basePath: false, }, { source: "/ingest/:path*", destination: "https://app.posthog.com/:path*", permanent: false, + basePath: false, + }, + { + source: "/home/install-docs-mcp", + destination: "/v1/setup/using-coding-agents", + permanent: true, }, { source: "/home/basics/overview", - destination: "/home/setup", + destination: "/v1/setup/advanced", permanent: false, + basePath: false, }, { source: "/home/basics/(steps|workflows)", - destination: "/home/your-first-task", + destination: "/v1/essentials/your-first-task", permanent: false, + basePath: false, }, { source: "/home/basics/environments", - destination: "/home/environments", + destination: "/v1/setup/advanced/environments", permanent: false, + basePath: false, }, { source: "/home/features/concurrency/:path*", - destination: "/home/concurrency", + destination: "/v1/concepts/concurrency", permanent: false, + basePath: false, }, { source: "/home/features/durable-execution", - destination: "/home/durable-execution", + destination: "/v1/concepts/durable-workflows/durable-task-execution", permanent: false, + basePath: false, }, { source: "/home/features/retries/:path*", - destination: "/home/retry-policies", + destination: "/v1/concepts/retry-policies", permanent: false, + basePath: false, }, { source: "/home/features/errors-and-logging", - destination: "/home/logging", + destination: "/v1/concepts/logging", permanent: false, + basePath: false, }, { source: "/home/features/on-failure-step", - destination: "/home/on-failure-tasks", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/on-failure-tasks", permanent: false, + basePath: false, }, { source: "/home/features/triggering-runs/event-trigger", - destination: "/home/run-on-event", + destination: "/v1/concepts/run-on-event", permanent: false, + basePath: false, }, { source: "/home/features/triggering-runs/cron-trigger", - destination: "/home/cron-runs", + destination: "/v1/concepts/cron-runs", permanent: false, + basePath: false, }, { source: "/home/features/triggering-runs/schedule-trigger", - destination: "/home/scheduled-runs", + destination: "/v1/concepts/scheduled-runs", permanent: false, + basePath: false, }, { source: "/home/features/rate-limits", - destination: "/home/rate-limits", + destination: "/v1/concepts/rate-limits", permanent: false, + basePath: false, }, { source: "/home/features/worker-assignment/overview", - destination: "/home/sticky-assignment", + destination: "/v1/concepts/sticky-assignment", permanent: false, + basePath: false, }, { source: "/home/features/worker-assignment/(overview|sticky-assignment)", - destination: "/home/sticky-assignment", + destination: "/v1/concepts/sticky-assignment", permanent: false, + basePath: false, }, { source: "/home/features/worker-assignment/worker-affinity", - destination: "/home/worker-affinity", + destination: "/v1/concepts/worker-affinity", permanent: false, + basePath: false, }, { source: "/home/features/additional-metadata", - destination: "/home/additional-metadata", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/additional-metadata", permanent: false, + basePath: false, }, { source: "/home/features/advanced/manual-slot-release", - destination: "/home/manual-slot-release", + destination: "/v1/concepts/manual-slot-release", permanent: false, + basePath: false, }, { source: "/home/features/opentelemetry", - destination: "/home/opentelemetry", + destination: "/v1/concepts/opentelemetry", permanent: false, + basePath: false, }, { source: "/home/features/cancellation", - destination: "/home/cancellation", + destination: "/v1/concepts/cancellation", permanent: false, + basePath: false, }, { source: "/home/features/child-workflows", - destination: "/home/child-spawning", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/child-spawning", permanent: false, + basePath: false, + }, + // Redirect old /concepts/X paths to new durable-workflows structure + { + source: "/concepts/workflows", + destination: "/concepts/durable-workflows", + permanent: true, + }, + { + source: "/concepts/dags", + destination: "/concepts/durable-workflows/directed-acyclic-graphs", + permanent: true, + }, + { + source: "/concepts/:slug(on-failure-tasks|child-spawning|additional-metadata)", + destination: "/concepts/durable-workflows/directed-acyclic-graphs/:slug", + permanent: true, + }, + { + source: "/concepts/conditional-workflows", + destination: "/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions", + permanent: true, + }, + { + source: "/concepts/durable-execution", + destination: "/concepts/durable-workflows/durable-task-execution", + permanent: true, + }, + { + source: "/concepts/:slug(durable-events|durable-sleep|durable-best-practices)", + destination: "/concepts/durable-workflows/durable-task-execution/:slug", + permanent: true, + }, + { + source: "/concepts/understanding-slots", + destination: "/concepts/workers#slots", + permanent: true, + }, + { + source: "/home/:slug(concurrency|rate-limits|priority|timeouts|retry-policies|bulk-retries-and-cancellations|sticky-assignment|worker-affinity|manual-slot-release|logging|opentelemetry|prometheus-metrics|cancellation|streaming|pushing-events|event-filters)", + destination: "/v1/concepts/:slug", + permanent: true, + basePath: false, + }, + { + source: "/home/:slug(dags|orchestration)", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs", + permanent: true, + basePath: false, + }, + { + source: "/home/:slug(on-failure-tasks|child-spawning|additional-metadata)", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/:slug", + permanent: true, + basePath: false, + }, + { + source: "/home/conditional-workflows", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions", + permanent: true, + basePath: false, + }, + { + source: "/home/durable-execution", + destination: "/v1/concepts/durable-workflows/durable-task-execution", + permanent: true, + basePath: false, + }, + { + source: "/home/:slug(durable-events|durable-sleep|durable-best-practices)", + destination: "/v1/concepts/durable-workflows/durable-task-execution/:slug", + permanent: true, + basePath: false, + }, + { + source: "/:slug(concurrency|rate-limits|priority|timeouts|retry-policies|bulk-retries-and-cancellations|sticky-assignment|worker-affinity|manual-slot-release|logging|opentelemetry|prometheus-metrics|cancellation|streaming|pushing-events|event-filters)", + destination: "/v1/concepts/:slug", + permanent: true, + basePath: false, + }, + { + source: "/:slug(dags|orchestration)", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs", + permanent: true, + basePath: false, + }, + { + source: "/:slug(on-failure-tasks|child-spawning|additional-metadata)", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/:slug", + permanent: true, + basePath: false, + }, + { + source: "/conditional-workflows", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions", + permanent: true, + basePath: false, + }, + { + source: "/durable-execution", + destination: "/v1/concepts/durable-workflows/durable-task-execution", + permanent: true, + basePath: false, + }, + { + source: "/:slug(durable-events|durable-sleep|durable-best-practices)", + destination: "/v1/concepts/durable-workflows/durable-task-execution/:slug", + permanent: true, + basePath: false, + }, + { + source: "/home/:slug(asyncio|pydantic|lifespans|dependency-injection|dataclasses)", + destination: "/v1/sdk/python/:slug", + permanent: true, + basePath: false, + }, + { + source: "/:slug(asyncio|pydantic|lifespans|dependency-injection|dataclasses)", + destination: "/v1/sdk/python/:slug", + permanent: true, + basePath: false, }, { source: "/sdks/python-sdk/:path*", - destination: "/sdks/python/client", + destination: "/v1/sdk/python/client", permanent: false, + basePath: false, }, { source: "/sdks/python", - destination: "/sdks/python/client", + destination: "/v1/sdk/python/client", + permanent: false, + basePath: false, + }, + { + source: "/sdks/:path*", + destination: "/v1/sdk/:path*", + permanent: false, + basePath: false, + }, + { + source: "/sdk/:path*", + destination: "/v1/sdk/:path*", + permanent: false, + basePath: false, + }, + { + source: "/cli/:path*", + destination: "/v1/cli/:path*", + permanent: false, + basePath: false, + }, + { + source: "/self-hosting/:path*", + destination: "/v1/self-hosting/:path*", + permanent: false, + basePath: false, + }, + { + source: "/concepts/:path*", + destination: "/v1/concepts/:path*", + permanent: false, + basePath: false, + }, + { + source: "/setup/:path*", + destination: "/v1/setup/:path*", + permanent: false, + basePath: false, + }, + { + source: "/essentials/:path*", + destination: "/v1/essentials/:path*", + permanent: false, + basePath: false, + }, + { + source: "/essentials/patterns", + destination: "/v1/patterns/fanout", + permanent: false, + basePath: false, + }, + { + source: "/essentials/patterns/:path*", + destination: "/v1/patterns/:path*", + permanent: false, + basePath: false, + }, + { + source: "/essentials/use-cases", + destination: "/v1/patterns/rag-and-indexing", + permanent: false, + basePath: false, + }, + { + source: "/essentials/use-cases/:path*", + destination: "/v1/patterns/:path*", + permanent: false, + basePath: false, + }, + { + source: "/essentials/v1-sdk-improvements", + destination: "/v1/migrating/v0-to-v1/v1-sdk-improvements", + permanent: false, + basePath: false, + }, + { + source: "/essentials/migration-guide-engine", + destination: "/v1/migrating/v0-to-v1/migration-guide-engine", + permanent: false, + basePath: false, + }, + { + source: "/essentials/migration-guide-python", + destination: "/v1/migrating/v0-to-v1/migration-guide-python", + permanent: false, + basePath: false, + }, + { + source: "/essentials/migration-guide-typescript", + destination: "/v1/migrating/v0-to-v1/migration-guide-typescript", + permanent: false, + basePath: false, + }, + { + source: "/essentials/migration-guide-go", + destination: "/v1/migrating/v0-to-v1/migration-guide-go", + permanent: false, + basePath: false, + }, + { + source: "/migrations/:path*", + destination: "/v1/migrating/v0-to-v1/:path*", + permanent: false, + basePath: false, + }, + { + source: "/home/:path*", + destination: "/v1/essentials/:path*", + permanent: false, + basePath: false, + }, + { + source: '/:path((?!api|ingest|v1|v0|compute|home|essentials|features|patterns|migrating|cli|sdk|sdks|contributing|self-hosting|launches|blog|llms|pages|favicon\\.ico|.*\\.png|.*\\.gif|.*\\.svg|_next/.*|monitoring\\-demo\\.mp4).*)', + destination: '/v1/essentials/:path*', permanent: false, + basePath: false, }, // Blog redirects to hatchet.run { source: "/blog/automated-documentation", destination: "https://hatchet.run/blog/automated-documentation", permanent: true, + basePath: false, }, { source: "/blog/background-tasks-fastapi-hatchet", destination: "https://hatchet.run/blog/fastapi-background-jobs-to-hatchet", permanent: true, + basePath: false, }, { source: "/blog/go-agents", destination: "https://hatchet.run/blog/go-agents", permanent: true, + basePath: false, }, { source: "/blog/warning-event-loop-blocked", destination: "https://hatchet.run/blog/warning-event-loop-blocked", permanent: true, + basePath: false, }, { source: "/blog/fastest-postgres-inserts", destination: "https://hatchet.run/blog/fastest-postgres-inserts", permanent: true, + basePath: false, }, { source: "/blog/task-queue-modern-python", destination: "https://hatchet.run/blog/task-queue-modern-python", permanent: true, + basePath: false, }, { source: "/blog/postgres-events-table", destination: "https://hatchet.run/blog/postgres-events-table", permanent: true, + basePath: false, }, { source: "/blog/migrating-off-prisma", destination: "https://hatchet.run/blog", permanent: true, + basePath: false, }, { source: "/blog/problems-with-celery", destination: "https://hatchet.run/blog/problems-with-celery", permanent: true, + basePath: false, }, { source: "/blog/multi-tenant-queues", destination: "https://hatchet.run/blog/multi-tenant-queues", permanent: true, + basePath: false, }, { source: "/blog/mergent-migration-guide", destination: "https://hatchet.run/blog", permanent: true, + basePath: false, }, { source: "/blog", destination: "https://hatchet.run/blog", permanent: true, - } + basePath: false, + }, + // Essentials durable-workflows subpage consolidation + { + source: "/v1/essentials/durable-workflows/durable-task-execution", + destination: "/v1/concepts/durable-workflows/durable-task-execution", + permanent: true, + basePath: false, + }, + { + source: "/v1/essentials/durable-workflows/directed-acyclic-graph", + destination: "/v1/concepts/durable-workflows/directed-acyclic-graphs", + permanent: true, + basePath: false, + }, + // Essentials → Concepts moves + { + source: "/essentials/docker", + destination: "/v1/concepts/docker", + permanent: true, + basePath: false, + }, + { + source: "/v1/essentials/docker", + destination: "/v1/concepts/docker", + permanent: true, + basePath: false, + }, + { + source: "/essentials/worker-healthchecks", + destination: "/v1/concepts/worker-healthchecks", + permanent: true, + basePath: false, + }, + { + source: "/v1/essentials/worker-healthchecks", + destination: "/v1/concepts/worker-healthchecks", + permanent: true, + basePath: false, + }, + { + source: "/essentials/autoscaling-workers", + destination: "/v1/concepts/autoscaling-workers", + permanent: true, + basePath: false, + }, + { + source: "/v1/essentials/autoscaling-workers", + destination: "/v1/concepts/autoscaling-workers", + permanent: true, + basePath: false, + }, ]; }, } diff --git a/frontend/docs/pages/_app.tsx b/frontend/docs/pages/_app.tsx index ea7fa61214..5f9d53edb4 100644 --- a/frontend/docs/pages/_app.tsx +++ b/frontend/docs/pages/_app.tsx @@ -5,6 +5,7 @@ import { ConsentProvider } from "../context/ConsentContext"; import CookieConsent from "@/components/ui/cookie-banner"; import { PostHogProvider } from "@/providers/posthog"; import { CrossDomainLinkHandler } from "@/components/CrossDomainLinkHandler"; +import { SidebarFolderNav } from "@/components/SidebarFolderNav"; function MyApp({ Component, pageProps }: AppProps) { return ( @@ -14,6 +15,7 @@ function MyApp({ Component, pageProps }: AppProps) {
+
diff --git a/frontend/docs/pages/_meta.js b/frontend/docs/pages/_meta.js index e27dad1a18..5667c13470 100644 --- a/frontend/docs/pages/_meta.js +++ b/frontend/docs/pages/_meta.js @@ -1,51 +1,78 @@ export default { - home: { - title: "Guide", + essentials: { + title: "Essentials", type: "page", theme: { toc: false, }, }, - _setup: { - display: "hidden", - }, - "self-hosting": { - title: "Self Hosting", + patterns: { + title: "Patterns", type: "page", theme: { toc: false, }, }, - contributing: { - title: "Contributing", + concepts: { + title: "Concepts", type: "page", + theme: { + toc: false, + }, + }, + _setup: { display: "hidden", + }, + "self-hosting": { + title: "Self Hosting", + type: "page", theme: { toc: false, }, }, - cli: { - title: "CLI Reference", + migrating: { + title: "Migrating", type: "page", theme: { toc: false, }, }, - sdks: { - title: "SDK Reference", + reference: { + title: "Reference", type: "menu", items: { + cli: { + title: "CLI Reference", + href: "/reference/cli", + type: "page", + }, python: { - title: "Python", - href: "/sdks/python/client", + title: "Python SDK", + href: "/reference/python/client", type: "page", }, go: { - title: "Go", + title: "Go SDK", href: "https://pkg.go.dev/github.com/hatchet-dev/hatchet/sdks/go", type: "page", newWindow: true, }, }, }, + contributing: { + title: "Contributing", + type: "page", + display: "hidden", + theme: { + toc: false, + }, + }, + "agent-instructions": { + title: "Agent Instructions", + type: "page", + display: "hidden", + theme: { + toc: false, + }, + }, }; diff --git a/frontend/docs/pages/agent-instructions/_meta.js b/frontend/docs/pages/agent-instructions/_meta.js new file mode 100644 index 0000000000..c62305b66d --- /dev/null +++ b/frontend/docs/pages/agent-instructions/_meta.js @@ -0,0 +1,7 @@ +export default { + "setup-cli": "Setup CLI", + "start-worker": "Start Worker", + "trigger-and-watch": "Trigger & Watch", + "debug-run": "Debug a Run", + "replay-run": "Replay a Run", +}; diff --git a/frontend/docs/pages/agent-instructions/debug-run.mdx b/frontend/docs/pages/agent-instructions/debug-run.mdx new file mode 100644 index 0000000000..fbaac16703 --- /dev/null +++ b/frontend/docs/pages/agent-instructions/debug-run.mdx @@ -0,0 +1,80 @@ +# Debug a Hatchet Run + +These are instructions for an AI agent to diagnose why a Hatchet run failed, is stuck, or behaved unexpectedly. Follow each step in order. + +## Step 1: Get Run State + +```bash +hatchet runs get RUN_ID -o json -p HATCHET_PROFILE +``` + +Examine the JSON response: + +- `.run.status` -- the overall run status (`QUEUED`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`) +- `.run.displayName` -- the workflow name +- `.run.input` -- the input that was provided when the workflow was triggered +- `.tasks[]` -- each task in the run, with its own `status`, `displayName`, and `output` or `errorMessage` +- `.tasks[].startedAt` and `.tasks[].finishedAt` -- timing information + +## Step 2: Get Event Log + +```bash +hatchet runs events RUN_ID -o json -p HATCHET_PROFILE +``` + +The event log shows the full lifecycle of the run. Each event has: + +- `eventType` -- what happened (e.g. `QUEUED`, `STARTED`, `FINISHED`, `FAILED`, `CANCELLED`) +- `message` -- human-readable description +- `taskDisplayName` -- which task the event belongs to +- `timestamp` -- when it happened + +Read events in chronological order to understand the sequence of what happened. + +## Step 3: Get Logs + +```bash +hatchet runs logs RUN_ID -p HATCHET_PROFILE +``` + +This prints the application-level log output from the task code (e.g. print statements, logger calls). For multi-task (DAG) runs, logs from all tasks are merged and sorted by timestamp with task name prefixes. + +## Diagnostic Cheat Sheet + +### Task stuck in QUEUED (no STARTED event) + +The task was never picked up by a worker. Likely causes: + +- **No worker is running.** Start one with `hatchet worker dev -p HATCHET_PROFILE`. +- **Worker does not register this task type.** The task name in the workflow definition must match what the worker code registers. Check the worker startup logs. +- **Worker is connected to a different tenant/profile.** Verify the worker and the trigger use the same `-p` profile. + +### Task FAILED + +Check the logs output from Step 3 for a stack trace or error message. Common causes: + +- Unhandled exception in the task code. +- Timeout exceeded. +- A dependency (database, API, etc.) was unreachable. + +The events from Step 2 will show a `FAILED` event with an error message. + +### Task CANCELLED + +Check the events for a `CANCELLED` event. The message field indicates the cancellation source: + +- Manual cancellation via the dashboard or CLI. +- Parent workflow cancellation (for DAG workflows). +- Timeout-based cancellation. + +### Run COMPLETED but output is wrong + +If the run completed but produced unexpected results: + +1. Check `.tasks[].output` in the run state (Step 1) to see what each task returned. +2. Check the logs (Step 3) to trace the execution flow. +3. Check `.run.input` to verify the correct input was provided. + +### Slow execution + +Compare `startedAt` and `finishedAt` timestamps for each task in Step 1 to identify which task is the bottleneck. Check if there is a long gap between the run being triggered and the first task starting (indicates queuing delay, possibly due to worker capacity). diff --git a/frontend/docs/pages/agent-instructions/replay-run.mdx b/frontend/docs/pages/agent-instructions/replay-run.mdx new file mode 100644 index 0000000000..eb56ea4afe --- /dev/null +++ b/frontend/docs/pages/agent-instructions/replay-run.mdx @@ -0,0 +1,78 @@ +# Replay a Hatchet Run + +These are instructions for an AI agent to replay a previously executed Hatchet run, optionally with modified input. Follow each step in order. + +## Step 1: Inspect the Original Run + +First, understand what happened in the original run: + +```bash +hatchet runs get RUN_ID -o json -p HATCHET_PROFILE +``` + +From the response, note: + +- `.run.displayName` -- the workflow name (needed if re-triggering with new input) +- `.run.input` -- the original input JSON +- `.run.status` -- why you might be replaying (e.g. `FAILED`) +- `.tasks[].status` and `.tasks[].errorMessage` -- which specific tasks failed and why + +## Step 2a: Replay with the Same Input + +If you want to re-run the exact same workflow with the same input (e.g. after fixing a bug in the task code): + +```bash +hatchet runs replay RUN_ID -o json -p HATCHET_PROFILE +``` + +This creates a new run of the same workflow with the same input. The response includes the new run IDs in `.ids[]`. + +## Step 2b: Replay with New Input + +If you need to change the input (e.g. fixing bad input data), you must trigger a new run instead of using replay: + +Write the new input JSON to a uniquely-named temp file: + +```bash +HATCHET_INPUT_FILE="/tmp/hatchet-input-$(date +%s)-$$.json" +cat > "$HATCHET_INPUT_FILE" << 'ENDJSON' +NEW_INPUT_JSON +ENDJSON +``` + +Then trigger a fresh run using the workflow name from Step 1: + +```bash +RUN_ID=$(hatchet trigger manual -w WORKFLOW_NAME -j "$HATCHET_INPUT_FILE" -p HATCHET_PROFILE -o json | jq -r '.runId') +``` + +Clean up the temp file: + +```bash +rm -f "$HATCHET_INPUT_FILE" +``` + +## Step 3: Watch the New Run + +After either Step 2a or 2b, you have a new run ID. Poll for completion: + +```bash +hatchet runs get -o json -p HATCHET_PROFILE +``` + +Check `.run.status` and `.tasks[].status` every 5 seconds until all reach a terminal state (`COMPLETED`, `FAILED`, `CANCELLED`). + +If the new run also fails, use the debug instructions: + +1. `hatchet runs logs -p HATCHET_PROFILE` for application logs +2. `hatchet runs events -o json -p HATCHET_PROFILE` for lifecycle events + +## Common Replay Workflow + +The typical flow when an agent is iterating on task code: + +1. Trigger a run and it fails +2. Read the logs/events to understand why +3. Fix the code (the worker auto-reloads if `reload: true` in `hatchet.yaml`) +4. Replay the run with `hatchet runs replay RUN_ID -o json -p HATCHET_PROFILE` +5. If it fails again, repeat from step 2 diff --git a/frontend/docs/pages/agent-instructions/setup-cli.mdx b/frontend/docs/pages/agent-instructions/setup-cli.mdx new file mode 100644 index 0000000000..7409dee62e --- /dev/null +++ b/frontend/docs/pages/agent-instructions/setup-cli.mdx @@ -0,0 +1,75 @@ +# Install and Set Up the Hatchet CLI + +These are instructions for an AI agent to install the Hatchet CLI and configure a profile. Follow each step in order. + +## Step 1: Check if Already Installed + +```bash +hatchet --version +``` + +If this prints a version number, the CLI is already installed. Skip to Step 3 (profile setup). + +If the command is not found, proceed to Step 2. + +## Step 2: Install the CLI + +On macOS, Linux, or WSL: + +```bash +curl -fsSL https://install.hatchet.run/install.sh | bash +``` + +Alternatively, on macOS via Homebrew: + +```bash +brew install hatchet-dev/hatchet/hatchet --cask +``` + +After installation, verify it worked: + +```bash +hatchet --version +``` + +## Step 3: Check for Existing Profiles + +```bash +hatchet profile list +``` + +If a profile already exists that connects to the correct Hatchet instance, note its name and use it as the `-p` flag in all subsequent commands. You are done. + +If no profiles exist or the correct one is missing, proceed to Step 4. + +## Step 4: Create a Profile + +You need a Hatchet API token. Ask the user for one if you do not have it. Then create a profile: + +```bash +hatchet profile add --name HATCHET_PROFILE --token +``` + +Replace `HATCHET_PROFILE` with a descriptive name (e.g. `local`, `staging`, `production`) and `` with the actual token. + +To set it as the default profile (so `-p` is optional in future commands): + +```bash +hatchet profile set-default --name HATCHET_PROFILE +``` + +## Step 5: Verify Connectivity + +Test that the profile works by listing workflows: + +```bash +hatchet runs list -o json -p HATCHET_PROFILE --since 1h --limit 1 +``` + +If this returns a JSON response (even with an empty rows list), the profile is correctly configured and connected. + +## Troubleshooting + +- **"command not found"** after install: The CLI binary may not be on your PATH. Check `~/.local/bin/hatchet` or re-run the install script. +- **Authentication error**: The API token may be invalid or expired. Ask the user for a new token and run `hatchet profile update`. +- **Connection refused**: The Hatchet server may not be running. For local development, start it with `hatchet server start`. diff --git a/frontend/docs/pages/agent-instructions/start-worker.mdx b/frontend/docs/pages/agent-instructions/start-worker.mdx new file mode 100644 index 0000000000..cab68c79a5 --- /dev/null +++ b/frontend/docs/pages/agent-instructions/start-worker.mdx @@ -0,0 +1,58 @@ +# Start a Hatchet Worker in Dev Mode + +These are instructions for an AI agent to start a Hatchet worker using the CLI. Follow each step in order. + +## Prerequisites + +You need a `hatchet.yaml` file in the project root. If one does not exist, create it with the following structure: + +```yaml +dev: + runCmd: "python src/worker.py" + files: + - "**/*.py" + reload: true +``` + +Adjust `runCmd` to match the project's language and entry point: + +- Python: `poetry run python src/worker.py` or `python src/worker.py` +- TypeScript/Node: `npx ts-node src/worker.ts` or `npm run dev` +- Go: `go run ./cmd/worker` + +The `files` list contains glob patterns for file watching. The `reload: true` setting enables automatic worker restart when watched files change. + +## Start the Worker + +Run the following command in a **background terminal** (the worker is a long-running process that must stay alive): + +```bash +hatchet worker dev -p HATCHET_PROFILE +``` + +The worker will connect to Hatchet using the specified profile and begin listening for tasks. + +## Important Notes + +- The worker **must be running** before you trigger any workflows. If a workflow is triggered with no worker running, tasks will remain in QUEUED status indefinitely. +- When `reload: true` is set, the worker automatically restarts when any watched file changes. This means you can edit task code and the worker picks up changes without manual restart. +- To disable auto-reload, add `--no-reload`. +- To override the run command without editing `hatchet.yaml`, use `--run-cmd "your command here"`. +- If the worker fails to start, check that the profile exists (`hatchet profile list`) and that the Hatchet server is reachable. + +## Optional: Pre-commands + +You can add setup commands that run before the worker starts: + +```yaml +dev: + preCmds: + - "poetry install" + - "npm install" + runCmd: "poetry run python src/worker.py" + files: + - "**/*.py" + reload: true +``` + +These run once each time the worker starts (including on reload). diff --git a/frontend/docs/pages/agent-instructions/trigger-and-watch.mdx b/frontend/docs/pages/agent-instructions/trigger-and-watch.mdx new file mode 100644 index 0000000000..9d071b66a2 --- /dev/null +++ b/frontend/docs/pages/agent-instructions/trigger-and-watch.mdx @@ -0,0 +1,75 @@ +# Trigger a Workflow and Watch for Completion + +These are instructions for an AI agent to trigger a Hatchet workflow and poll until it completes. Follow each step in order. + +## Prerequisites + +- A Hatchet worker must be running (`hatchet worker dev -p HATCHET_PROFILE`). If no worker is running, the task will stay QUEUED forever. +- You must know the workflow name and have the input JSON ready. + +## Step 1: Write Input to a Temp File + +Write the input JSON to a uniquely-named temp file to avoid collisions with other sessions: + +```bash +HATCHET_INPUT_FILE="/tmp/hatchet-input-$(date +%s)-$$.json" +cat > "$HATCHET_INPUT_FILE" << 'ENDJSON' +INPUT_JSON +ENDJSON +``` + +Replace `INPUT_JSON` with the actual JSON payload for the workflow. + +## Step 2: Trigger the Workflow + +```bash +RUN_ID=$(hatchet trigger manual -w WORKFLOW_NAME -j "$HATCHET_INPUT_FILE" -p HATCHET_PROFILE -o json | jq -r '.runId') +``` + +The `-o json` flag makes the command output `{"runId": "...", "workflow": "..."}` to stdout. The command above captures the run ID directly into `$RUN_ID` for the next steps. + +## Step 3: Poll for Completion + +Run the following command every 5 seconds until the run reaches a terminal state: + +```bash +hatchet runs get -o json -p HATCHET_PROFILE +``` + +Parse the JSON response and check the status: + +- Look at `.run.status` for the overall run status and `.tasks[].status` for individual task statuses. +- Terminal statuses are: `COMPLETED`, `FAILED`, `CANCELLED`. +- Non-terminal statuses are: `QUEUED`, `RUNNING`. Keep polling if you see these. + +## Step 4: Handle Failure + +If the run status is `FAILED`, gather diagnostic information: + +### Fetch logs + +```bash +hatchet runs logs -p HATCHET_PROFILE +``` + +This prints application-level log output (e.g. print statements, logger calls from your task code). Look for error messages, stack traces, or unexpected output. + +### Fetch events + +```bash +hatchet runs events -o json -p HATCHET_PROFILE +``` + +This returns the lifecycle event log showing how the task was dispatched, started, and failed. Look at the `eventType` and `message` fields to understand the failure sequence. + +## Step 5: Clean Up + +```bash +rm -f "$HATCHET_INPUT_FILE" +``` + +## Common Issues + +- **Task stays QUEUED**: The worker is not running, or the workflow/task name does not match what the worker registered. Start or restart the worker. +- **Task FAILED immediately**: Check the logs for a stack trace. The task code likely threw an exception. +- **Task CANCELLED**: Something cancelled the run externally. Check events to see the cancellation source. diff --git a/frontend/docs/pages/api/mcp.ts b/frontend/docs/pages/api/mcp.ts index 4d2a5c9785..df9908c876 100644 --- a/frontend/docs/pages/api/mcp.ts +++ b/frontend/docs/pages/api/mcp.ts @@ -302,15 +302,6 @@ function handleToolsList(id: string | number | null): JsonRpcResponse { required: ["query"], }, }, - { - name: "get_full_docs", - description: - "Get the complete Hatchet documentation as a single document. Useful for comprehensive context.", - inputSchema: { - type: "object", - properties: {}, - }, - }, ], }, }; @@ -471,6 +462,24 @@ function handleGetFullDocs(id: string | number | null): JsonRpcResponse { }; } +// --------------------------------------------------------------------------- +// Agent instruction tools +// --------------------------------------------------------------------------- +const PAGES_DIR = path.join(process.cwd(), "pages", "agent-instructions"); + +function readAgentPage(slug: string): string | null { + // Try generated markdown first, fall back to MDX source + const llmsPath = path.join(LLMS_DIR, "agent-instructions", `${slug}.md`); + if (fs.existsSync(llmsPath)) { + return fs.readFileSync(llmsPath, "utf-8"); + } + const mdxPath = path.join(PAGES_DIR, `${slug}.mdx`); + if (fs.existsSync(mdxPath)) { + return fs.readFileSync(mdxPath, "utf-8"); + } + return null; +} + // --------------------------------------------------------------------------- // Notifications (no response needed) // --------------------------------------------------------------------------- diff --git a/frontend/docs/pages/concepts/_meta.js b/frontend/docs/pages/concepts/_meta.js new file mode 100644 index 0000000000..cf2fa1efc8 --- /dev/null +++ b/frontend/docs/pages/concepts/_meta.js @@ -0,0 +1,75 @@ +export default { + "getting-started": { + title: "Getting Started ↗", + href: "/essentials/quickstart", + }, + "--runnables": { + title: "Runnables", + type: "separator", + }, + tasks: "Tasks", + "durable-workflows": { + title: "Durable Workflows", + theme: { collapsed: true }, + }, + "run-with-results": "Run and Wait Trigger", + "run-no-wait": "Run Without Wait Trigger", + "scheduled-runs": "Scheduled Trigger", + "cron-runs": "Cron Trigger", + "bulk-run": "Bulk Run Many", + webhooks: "Webhooks", + "inter-service-triggering": "Inter-Service Triggering", + "--runtime": { + title: "Runtime", + type: "separator", + }, + workers: "Workers", + docker: "Running with Docker", + "autoscaling-workers": "Autoscaling Workers", + "--flow-control": { + title: "Flow Control", + type: "separator", + }, + concurrency: "Concurrency", + "rate-limits": "Rate Limits", + priority: "Priority", + "--error-handling": { + title: "Error Handling", + type: "separator", + }, + timeouts: "Timeouts", + "retry-policies": "Retry Policies", + "bulk-retries-and-cancellations": "Bulk Retries and Cancellations", + "--external-events": { + title: "External Events", + type: "separator", + }, + "pushing-events": "Pushing Events", + "run-on-event": "Event Trigger", + "event-filters": "Event Filters", + "--assignment": { + title: "Advanced Assignment", + type: "separator", + }, + "sticky-assignment": "Sticky Assignment", + "worker-affinity": "Worker Affinity", + "manual-slot-release": "Manual Slot Release", + "--observability": { + title: "Observability", + type: "separator", + }, + "worker-healthchecks": "Worker Health Checks", + logging: "Logging", + opentelemetry: "OpenTelemetry", + "prometheus-metrics": "Prometheus Metrics", + "--advanced-tasks": { + title: "Advanced Task Features", + type: "separator", + }, + cancellation: { + title: "Cancellation", + }, + streaming: { + title: "Streaming", + }, +}; diff --git a/frontend/docs/pages/home/autoscaling-workers.mdx b/frontend/docs/pages/concepts/autoscaling-workers.mdx similarity index 100% rename from frontend/docs/pages/home/autoscaling-workers.mdx rename to frontend/docs/pages/concepts/autoscaling-workers.mdx diff --git a/frontend/docs/pages/home/bulk-retries-and-cancellations.mdx b/frontend/docs/pages/concepts/bulk-retries-and-cancellations.mdx similarity index 100% rename from frontend/docs/pages/home/bulk-retries-and-cancellations.mdx rename to frontend/docs/pages/concepts/bulk-retries-and-cancellations.mdx diff --git a/frontend/docs/pages/home/bulk-run.mdx b/frontend/docs/pages/concepts/bulk-run.mdx similarity index 100% rename from frontend/docs/pages/home/bulk-run.mdx rename to frontend/docs/pages/concepts/bulk-run.mdx diff --git a/frontend/docs/pages/home/cancellation.mdx b/frontend/docs/pages/concepts/cancellation.mdx similarity index 72% rename from frontend/docs/pages/home/cancellation.mdx rename to frontend/docs/pages/concepts/cancellation.mdx index 88675a180b..7fce04cd7e 100644 --- a/frontend/docs/pages/home/cancellation.mdx +++ b/frontend/docs/pages/concepts/cancellation.mdx @@ -22,38 +22,15 @@ When a task is canceled, Hatchet sends a cancellation signal to the task. The ta /> -### CancelledError Exception - -When a sync task is cancelled while waiting for a child workflow or during a cancellation-aware operation, a `CancelledError` exception is raised. - - - **Important:** `CancelledError` inherits from `BaseException`, not - `Exception`. This means it will **not** be caught by bare `except Exception:` - handlers. This is intentional and mirrors the behavior of Python's - `asyncio.CancelledError`. - - - - -### Cancellation Reasons - -The `CancelledError` includes a `reason` attribute that indicates why the cancellation occurred: - -| Reason | Description | -| --------------------------------------- | --------------------------------------------------------------------- | -| `CancellationReason.USER_REQUESTED` | The user explicitly requested cancellation via `ctx.cancel()` | -| `CancellationReason.WORKFLOW_CANCELLED` | The workflow run was cancelled (e.g., via API or concurrency control) | -| `CancellationReason.PARENT_CANCELLED` | The parent workflow was cancelled while waiting for a child | -| `CancellationReason.TIMEOUT` | The operation timed out | -| `CancellationReason.UNKNOWN` | Unknown or unspecified reason | - diff --git a/frontend/docs/pages/home/concurrency.mdx b/frontend/docs/pages/concepts/concurrency.mdx similarity index 100% rename from frontend/docs/pages/home/concurrency.mdx rename to frontend/docs/pages/concepts/concurrency.mdx diff --git a/frontend/docs/pages/home/cron-runs.mdx b/frontend/docs/pages/concepts/cron-runs.mdx similarity index 91% rename from frontend/docs/pages/home/cron-runs.mdx rename to frontend/docs/pages/concepts/cron-runs.mdx index 0367e45dc7..96b6421f86 100644 --- a/frontend/docs/pages/home/cron-runs.mdx +++ b/frontend/docs/pages/concepts/cron-runs.mdx @@ -5,7 +5,7 @@ import UniversalTabs from "@/components/UniversalTabs"; # Recurring Runs with Cron -> This example assumes we have a [task](./your-first-task.mdx) registered on a running [worker](./workers.mdx). +> This example assumes we have a [task](/essentials/your-first-task) registered on a running [worker](/essentials/workers). A [Cron](https://en.wikipedia.org/wiki/Cron) is a time-based job scheduler that allows you to define when a task should be executed automatically on a pre-determined schedule. @@ -17,9 +17,9 @@ Some example use cases for cron-style tasks might include: Hatchet supports cron triggers to run on a schedule defined in a few different ways: -- [Task Definitions](./cron-runs.mdx#defining-a-cron-in-your-task-definition): Define a cron expression in your task definition to trigger the task on a predefined schedule. -- [Dynamic Programmatically](./cron-runs.mdx#programmatically-creating-cron-triggers): Use the Hatchet SDKs to dynamically set the cron schedule of a task. -- [Hatchet Dashboard](./cron-runs.mdx#managing-cron-jobs-in-the-hatchet-dashboard): Manually create cron triggers from the Hatchet Dashboard. +- [Task Definitions](/concepts/cron-runs#defining-a-cron-in-your-task-definition): Define a cron expression in your task definition to trigger the task on a predefined schedule. +- [Dynamic Programmatically](/concepts/cron-runs#programmatically-creating-cron-triggers): Use the Hatchet SDKs to dynamically set the cron schedule of a task. +- [Hatchet Dashboard](/concepts/cron-runs#managing-cron-jobs-in-the-hatchet-dashboard): Manually create cron triggers from the Hatchet Dashboard. The expression is when Hatchet **enqueues** the task, not when the run starts. @@ -200,4 +200,4 @@ When using cron triggers, there are a few considerations to keep in mind: 3. **Missed Schedules**: If a scheduled task is missed (e.g., due to system downtime), Hatchet will **not** automatically run the missed instances. It will wait for the next scheduled time to trigger the task. -4. **Overlapping Schedules**: If a task is still running when the next scheduled time arrives, Hatchet will start a new instance of the task or respect the [concurrency](./concurrency.mdx) policy. +4. **Overlapping Schedules**: If a task is still running when the next scheduled time arrives, Hatchet will start a new instance of the task or respect the [concurrency](/concepts/concurrency) policy. diff --git a/frontend/docs/pages/home/docker.mdx b/frontend/docs/pages/concepts/docker.mdx similarity index 100% rename from frontend/docs/pages/home/docker.mdx rename to frontend/docs/pages/concepts/docker.mdx diff --git a/frontend/docs/pages/concepts/durable-workflows/_meta.js b/frontend/docs/pages/concepts/durable-workflows/_meta.js new file mode 100644 index 0000000000..2cc11d8177 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/_meta.js @@ -0,0 +1,12 @@ +export default { + index: "Overview", + "durable-task-execution": { + title: "Durable Task Execution", + theme: { collapsed: true }, + }, + "directed-acyclic-graphs": { + title: "Directed Acyclic Graphs", + theme: { collapsed: true }, + }, + "mixing-patterns": "Mixing Patterns", +}; diff --git a/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/_meta.js b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/_meta.js new file mode 100644 index 0000000000..935123b9cc --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/_meta.js @@ -0,0 +1,10 @@ +export default { + index: "Directed Acyclic Graphs (DAGs)", + "parent-conditions": "Parent Conditions", + "event-conditions": "Event Conditions", + "sleep-conditions": "Sleep Conditions", + "combining-conditions": "Combining Conditions", + "on-failure-tasks": "On Failure Tasks", + "child-spawning": "Child Spawning", + "additional-metadata": "Additional Metadata", +}; diff --git a/frontend/docs/pages/home/additional-metadata.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/additional-metadata.mdx similarity index 97% rename from frontend/docs/pages/home/additional-metadata.mdx rename to frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/additional-metadata.mdx index ab11da06b1..ffd416dc83 100644 --- a/frontend/docs/pages/home/additional-metadata.mdx +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/additional-metadata.mdx @@ -1,5 +1,5 @@ import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "../../components/UniversalTabs"; +import UniversalTabs from "../../../../components/UniversalTabs"; import { Snippet } from "@/components/code"; import { snippets } from "@/lib/generated/snippets"; diff --git a/frontend/docs/pages/home/child-spawning.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/child-spawning.mdx similarity index 100% rename from frontend/docs/pages/home/child-spawning.mdx rename to frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/child-spawning.mdx diff --git a/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions.mdx new file mode 100644 index 0000000000..2074a019ba --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions.mdx @@ -0,0 +1,81 @@ +import { Snippet } from "@/components/code"; +import { snippets } from "@/lib/generated/snippets"; +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Combining Conditions + +DAG tasks can declare multiple conditions that work together to control when and whether a task runs. Conditions of different types — [parent conditions](/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions), [event conditions](/concepts/durable-workflows/directed-acyclic-graphs/event-conditions), and [sleep conditions](/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions) — can be mixed on a single task using **or groups**. + +## Or groups + +An **or group** is a set of conditions combined with an `Or` operator. The group evaluates to `True` if **at least one** of its conditions is satisfied. Multiple or groups on the same task are combined with `AND` — every group must have at least one satisfied condition for the task to proceed. + +This lets you express arbitrarily complex sets of conditions in [conjunctive normal form](https://en.wikipedia.org/wiki/Conjunctive_normal_form) (CNF). + +## Sleep + Event example + +The most common combination is a sleep condition with an event condition: proceed when an external signal arrives _or_ after a timeout (whichever comes first). This is ideal for human-in-the-loop workflows where you want a deadline. + + + + + + `or_()` wraps a `SleepCondition` and a `UserEventCondition` into a single or group. The task will start as soon as either the sleep expires or the event arrives. + + + + + + `Or()` wraps a `SleepCondition` and a `UserEventCondition` into a single or group. The task will start as soon as either the sleep expires or the event arrives. + + + + + + `hatchet.WithWaitFor` and `hatchet.WithSkipIf` attach conditions to the task. The task will wait for the sleep to expire before starting, and will be skipped if the event arrives. + + + + + + `Hatchet.or_()` wraps a `SleepCondition` and a `UserEventCondition` into a single or group. The task will start as soon as either the sleep expires or the event arrives. + + + + +## Multiple or groups + +For more complex logic, you can declare multiple or groups on a single task. Consider three conditions: + +- **Condition A**: Parent output is greater than 50 +- **Condition B**: Sleep for 30 seconds +- **Condition C**: Receive the `payment:processed` event + +To proceed if (A _or_ B) **and** (A _or_ C), declare two or groups: + +1. Group 1: `A or B` +2. Group 2: `A or C` + +The task will run once both groups are satisfied. If A is true, both groups pass immediately. If A is false, the task needs both B (sleep expires) and C (event arrives). + +## Common combinations + +| Combination | Use case | +| -------------- | ------------------------------------------------------------------------------------ | +| Sleep + Event | Proceed after a timeout _or_ when an external signal arrives (whichever comes first) | +| Parent + Event | Proceed if a parent output meets a threshold _or_ a manual override event arrives | +| Parent + Sleep | Proceed if a parent indicates readiness _or_ after a maximum wait time | +| All three | Complex gates combining data-driven, time-based, and event-driven conditions | + + + Durable tasks have an analogous feature: [or groups in durable wait + conditions](/concepts/durable-workflows/durable-task-execution#or-groups). In + a DAG, or groups are declared upfront on the task definition. In a durable + task, they're passed to `WaitFor` at runtime. + diff --git a/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/event-conditions.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/event-conditions.mdx new file mode 100644 index 0000000000..190f331d7d --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/event-conditions.mdx @@ -0,0 +1,106 @@ +import { Snippet } from "@/components/code"; +import { snippets } from "@/lib/generated/snippets"; + +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Event Conditions + +Event conditions let a DAG task react to external events. A task can wait for an event before running, be skipped when an event arrives, or be cancelled by an event. + + + Durable tasks have an analogous feature: [Wait For + Events](/concepts/durable-workflows/durable-task-execution/durable-events). In + a DAG, event conditions are declared upfront on the task definition. In a + durable task, `WaitForEvent` is called at runtime from within the function + body. Use a DAG event condition when the wait is part of a fixed pipeline; use + a durable task when the decision to wait is made dynamically. + + +## Usage modes + +Event conditions can be used with three operators: + +- **`wait_for`** — the task waits for the event before starting. +- **`skip_if`** — the task is skipped if the event arrives. +- **`cancel_if`** — the task is cancelled if the event arrives. + + + A task cancelled by `cancel_if` behaves like any other cancellation in Hatchet + — downstream tasks will be cancelled as well. + + +## Waiting for an event + +Declare a task with a `wait_for` event condition. The task will not start until the specified event is pushed into Hatchet. + + + + + + + + + + + + + + + + +## Skipping on an event + +Declare a task with a `skip_if` event condition. The task will be skipped if the event arrives before the task starts. + + + + + + + + + + + + + + + + +## Event filters + +Events can be filtered using [CEL](https://github.com/google/cel-spec) expressions. For example, to only receive `user:update` events for a specific user, you can filter on the event payload. This works identically to [event filters in durable tasks](/concepts/durable-workflows/durable-task-execution/durable-events#event-filters) — the CEL expression is evaluated against the event payload, and the condition only matches if the expression returns `true`. + +## Pushing events + +For a waiting task to proceed, something must [push an event](/concepts/pushing-events) into Hatchet with a matching key. You can do this from any service that has access to the Hatchet client — it does not need to be the same worker. + + + + + + + + + + + + + + + + +## Combining with other conditions + +Event conditions can be combined with parent and sleep conditions using or groups. For example, you can wait for _either_ an event or a timeout (whichever comes first). See [Combining Conditions](/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions) for details. diff --git a/frontend/docs/pages/home/dags.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/index.mdx similarity index 79% rename from frontend/docs/pages/home/dags.mdx rename to frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/index.mdx index 9b6e1dea93..9c55ab6012 100644 --- a/frontend/docs/pages/home/dags.mdx +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/index.mdx @@ -2,16 +2,47 @@ import { snippets } from "@/lib/generated/snippets"; import { Snippet } from "@/components/code"; import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; import UniversalTabs from "@/components/UniversalTabs"; +import WorkflowDiagram from "@/components/WorkflowDiagram"; # Declarative Workflow Design (DAGs) -Hatchet workflows are designed in a **Directed Acyclic Graph (DAG)** format, where each task is a node in the graph, and the dependencies between tasks are the edges. This structure ensures that workflows are organized, predictable, and free from circular dependencies. By defining the sequence and dependencies of tasks upfront, you can easily understand the actual runtime state as compared to the expected state when debugging or troubleshooting. + + Just getting started? Check out [Intro to Durable + Workflows](/essentials/durable-workflows) for a high-level overview of how + durable workflows fit together. + + +Hatchet workflows are designed in a **Directed Acyclic Graph (DAG)** format, where each task is a node in the graph, and the dependencies between tasks are the edges. This structure ensures that workflows are organized, predictable, and free from circular dependencies. + + + +## How DAG Workflows Work + + + +### You declare the graph + +Define tasks and their dependencies upfront. Hatchet knows the full shape of work before execution begins. + +### Hatchet executes in order + +Tasks run as soon as their parents complete. Independent tasks run in parallel automatically. A worker slot is only assigned when a task is ready to execute, so tasks waiting on parents consume no resources. Each task has configurable [retry policies](/concepts/retry-policies) and [timeouts](/concepts/timeouts). + +### Results flow downstream + +Task outputs are cached and passed to child tasks. If a failure occurs mid-workflow, completed tasks don't re-run. + +### Everything is observable + +Every task execution is tracked in the dashboard — inputs, outputs, durations, and errors. You can see exactly where a workflow succeeded or failed. + + ## Defining a Workflow Start by declaring a workflow with a name. The workflow object can declare additional workflow-level configuration options which we'll cover later. -The returned object is an instance of the `Workflow` class, which is the primary interface for interacting with the workflow (i.e. [running](./run-with-results.mdx), [enqueuing](./run-no-wait.mdx), [scheduling](./scheduled-runs.mdx), etc). +The returned object is an instance of the `Workflow` class, which is the primary interface for interacting with the workflow (i.e. [running](/concepts/run-with-results), [enqueuing](/concepts/run-no-wait), [scheduling](/concepts/scheduled-runs), etc). @@ -36,8 +67,8 @@ The returned object is an instance of the `Workflow` class, which is the primary The Workflow return object can be interacted with in the same way as a - [task](./your-first-task.mdx), however, it can only take a subset of options - which are applied at the task level. + [task](/essentials/your-first-task), however, it can only take a subset of + options which are applied at the task level. ## Defining a Task diff --git a/frontend/docs/pages/home/on-failure-tasks.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/on-failure-tasks.mdx similarity index 100% rename from frontend/docs/pages/home/on-failure-tasks.mdx rename to frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/on-failure-tasks.mdx diff --git a/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions.mdx new file mode 100644 index 0000000000..e3b6439341 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions.mdx @@ -0,0 +1,93 @@ +import { Snippet } from "@/components/code"; +import { snippets } from "@/lib/generated/snippets"; + +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Parent Conditions + +Parent conditions let a DAG task decide whether to run based on the output of a parent task. This enables branching logic within a DAG: different paths can execute depending on runtime data, while the overall graph structure remains fixed and visible in the dashboard. + +## Usage modes + +Parent conditions can be used with two operators: + +- **`skip_if`** — skip the task if the parent output matches the condition. +- **`cancel_if`** — cancel the task (and its downstream dependents) if the parent output matches the condition. + + + A task cancelled by `cancel_if` behaves like any other cancellation in Hatchet + — downstream tasks will be cancelled as well. + + +## Branching example + +A common pattern is to create two sibling tasks with complementary parent conditions. For example, one task runs when a value is greater than 50 and the other runs when it is less than or equal to 50. Only one branch executes per run. + +First, declare a base task that returns a value: + + + + + + + + + + + + + + + + +Then add two branches that use `ParentCondition` with `skip_if`: + + + + + + + + + + + + + + + + +These two tasks check whether the output of the base task was greater or less than `50`, respectively. Only one of the two will run per workflow execution. + +## Checking if a task was skipped + +Downstream tasks can check whether a parent was skipped using `ctx.was_skipped`: + + + + + + + + + + + + + + + + + +## Combining with other conditions + +Parent conditions can be combined with event and sleep conditions using or groups. See [Combining Conditions](/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions) for details. diff --git a/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions.mdx b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions.mdx new file mode 100644 index 0000000000..8de9954c2b --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions.mdx @@ -0,0 +1,48 @@ +import { Snippet } from "@/components/code"; +import { snippets } from "@/lib/generated/snippets"; + +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Sleep Conditions + +Sleep conditions pause a DAG task for a specified duration before it runs. Use them when a task should wait for a fixed amount of time after its parent tasks complete. + + + Durable tasks have an analogous feature: [Durable + Sleep](/concepts/durable-workflows/durable-task-execution/durable-sleep). In a + DAG, sleep conditions are declared upfront on the task definition. In a + durable task, `SleepFor` is called at runtime from within the function body. + Use a DAG sleep condition when the delay is part of a fixed pipeline; use a + durable task when the decision to sleep is made dynamically. + + +## Using sleep conditions + +Declare a task with a `wait_for` sleep condition. The task will wait for its parent tasks to complete, then sleep for the specified duration before executing. + + + + + + + + + + + + + + + + +This task will first wait for its parent to complete, then sleep for the specified duration before executing. + +## Combining with other conditions + +Sleep conditions can be combined with other conditions using or groups. For example, you can wait for _either_ a sleep duration or an event (whichever comes first). See [Combining Conditions](/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions) for details. diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/_meta.js b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/_meta.js new file mode 100644 index 0000000000..f75045c2cf --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/_meta.js @@ -0,0 +1,8 @@ +export default { + index: "Understanding Durable Execution", + "child-spawning": "Child Spawning", + "durable-sleep": "Durable Sleep", + "durable-events": "Wait For Events", + "durable-best-practices": "Best Practices", + "task-eviction": "Task Eviction", +}; diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/child-spawning.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/child-spawning.mdx new file mode 100644 index 0000000000..22f0da5baa --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/child-spawning.mdx @@ -0,0 +1,133 @@ +import { Callout, Cards, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; +import { Snippet } from "@/components/code"; +import { snippets } from "@/lib/generated/snippets"; + +# Spawning Children from Durable Tasks + +A durable task can spawn child tasks - including other durable tasks or entire DAG workflows - at runtime. This is one of the core reasons to choose durable tasks over DAGs: the shape of work is decided as the task runs, not declared upfront. + + + Waiting for child results puts the parent task into an [evictable + state](/concepts/durable-workflows/durable-task-execution/task-eviction) — the + worker slot is freed and the parent is re-queued when results are available. + + +## Why spawn from a durable task? + +Because the parent is evicted while children execute, this means: + +- **No slot waste** - the parent doesn't hold a worker slot while N children run across your fleet. +- **No deadlocks** - because the parent is evicted, it can't starve its own children for slots. +- **Dynamic N** - you decide how many children to spawn based on runtime data (input size, API responses, agent reasoning). + +## Spawning child tasks + +Use the context object to spawn a child task from within a durable task. The child runs independently on any available worker. + + + + + + + + + + + + + + + + + + + + + + +## Parallel fan-out + +Spawn many children at once and wait for all results. The parent is evicted during the wait, so it consumes no resources while children run. + + + + + + + + + + + + + + + + + + + + + + +## What children can be + +A durable task can spawn any runnable - the child doesn't have to be the same type as the parent: + +| Child type | Example | +| ---------------- | --------------------------------------------------------------------------------- | +| **Regular task** | Spawn a stateless task for a quick computation or API call. | +| **Durable task** | Spawn another durable task that has its own checkpoints, sleeps, and event waits. | +| **DAG workflow** | Spawn an entire multi-task workflow and wait for its final output. | + +This means a single durable task can orchestrate a mix of simple tasks and complex workflows, all determined at runtime. + +## Common patterns + +### Dynamic fan-out / fan-in + +Process a list of items whose length is only known at runtime. Spawn one child per item, collect all results, then continue. + +### Agent loops + +An AI agent reasons about what to do, spawns a subtask (or a sub-workflow), inspects the result, and decides whether to continue, branch, or stop. Each iteration spawns new children dynamically. + +### Recursive workflows + +A durable task spawns child durable tasks, each of which may spawn their own children. This creates a tree of work that's entirely driven by runtime logic - useful for crawlers, recursive search, and tree-structured computations. + +## Error handling + + + + + + + + + + + + + + + + + + + + + + + + For the general child spawning API (task definitions, options, deduplication), + see [Procedural Child Task + Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning). + diff --git a/frontend/docs/pages/home/durable-best-practices.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-best-practices.mdx similarity index 66% rename from frontend/docs/pages/home/durable-best-practices.mdx rename to frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-best-practices.mdx index d4f064b065..bdabdd714d 100644 --- a/frontend/docs/pages/home/durable-best-practices.mdx +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-best-practices.mdx @@ -1,4 +1,4 @@ -## Durable Execution Best Practices +# Durable Execution Best Practices Durable tasks require a bit of extra work to ensure that they are not misused. An important concept in running a durable task is that the task should be **deterministic**. This means that the task should always perform the same sequence of operations in between retries. @@ -8,12 +8,12 @@ The deterministic nature of durable tasks is what allows Hatchet to replay the t By following a few simple rules, you can ensure that your durable tasks are deterministic: -1. **Only call methods available on the `DurableContext`**: a very common way to introduce non-determinism is to call methods within your application code which produces side effects. If you need to call a method in your application code which fetches data from a database, calls any sort of i/o operation, or otherwise interacts with other systems, you should spawn those tasks as a **child task** or **child workflow** using `RunChild`. +1. **Only call methods available on the `DurableContext`**: a very common way to introduce non-determinism is to call methods within your application code which produces side effects. If you need to call a method in your application code which fetches data from a database, calls any sort of i/o operation, or otherwise interacts with other systems, you should spawn those tasks as a **child task** or **child workflow** using `RunChild`. Remember that durable tasks are [evicted](/concepts/durable-workflows/durable-task-execution/task-eviction) at every wait point and replayed from checkpoint on resume — any side effect not behind a checkpoint will re-execute. 2. **When updating durable tasks, always guarantee backwards compatibility**: if you change the order of operations in a durable task, you may break determinism. For example, if you call `SleepFor` followed by `WaitFor`, and then change the order of those calls, Hatchet will not be able to replay the task correctly. This is because the task may have already been checkpointed at the first call to `SleepFor`, and if you change the order of operations, the checkpoint is meaningless. ## Using DAGs instead of durable tasks -[DAGs](./dags) are generally a much easier, more intuitive way to run a durable, deterministic workflow. DAGs are inherently deterministic, as their shape of work is predefined, and they cache intermediate results. If you are running simple workflows that can be represented as a DAG, you should use DAGs instead of durable tasks. DAGs also have conditional execution primitives which match the behavior of `SleepFor` and `WaitFor` in durable tasks. +[DAGs](/concepts/durable-workflows/directed-acyclic-graphs) are generally a much easier, more intuitive way to run a durable, deterministic workflow. DAGs are inherently deterministic, as their shape of work is predefined, and they cache intermediate results. If you are running simple workflows that can be represented as a DAG, you should use DAGs instead of durable tasks. DAGs also have conditional execution primitives which match the behavior of `SleepFor` and `WaitFor` in durable tasks. Durable tasks are useful if you need to run a workflow that is not easily represented as a DAG. diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-events.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-events.mdx new file mode 100644 index 0000000000..cefec453b1 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-events.mdx @@ -0,0 +1,96 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Wait For Events + +Wait For Events is a feature of **durable tasks** that lets a task pause until an external event arrives before continuing. Even if the task is interrupted and requeued while waiting, the event will still be processed — when it resumes, it reads the event from the durable event log and continues. + + + Waiting for an event puts the task into an [evictable + state](/concepts/durable-workflows/durable-task-execution/task-eviction) — the + worker slot is freed and the task is re-queued when the event arrives. + + + + DAG workflows have an analogous feature: [Event + Conditions](/concepts/durable-workflows/directed-acyclic-graphs/event-conditions). + In a DAG, event conditions are declared upfront on the task definition. Use a + durable task when the decision to wait is made dynamically at runtime. + + +Events are delivered to durable tasks by [pushing events](/concepts/pushing-events) into Hatchet using the event client. The event key you push must match the key your task is waiting for. + +## Declaring a wait for event + +Wait For Event is declared using the context method `WaitFor` (or utility method `WaitForEvent`) on the `DurableContext` object. + + + + + + + + + + + + + + + + + + + + + + +## Event filters + +Events can be filtered using [CEL](https://github.com/google/cel-spec) expressions. For example, to only receive `user:update` events for a specific user, you can use the following filter: + + + + + + + + + + + + + + + + + + + + + + +## Pushing events + +For a waiting task to resume, something must [push an event](/concepts/pushing-events) into Hatchet with a matching key. You can do this from any service that has access to the Hatchet client - it does not need to be the same worker. + + + + + + + + + + + + + + + + +When the pushed event's key matches what a durable task is waiting for (and passes any CEL filter), the task is re-queued and resumes from its checkpoint. diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-sleep.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-sleep.mdx new file mode 100644 index 0000000000..f805878363 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/durable-sleep.mdx @@ -0,0 +1,50 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Durable Sleep + +Durable sleep is a feature of **durable tasks** which pauses execution for a specified amount of time. + + + Sleeping puts the task into an [evictable + state](/concepts/durable-workflows/durable-task-execution/task-eviction) — the + worker slot is freed and the task is re-queued when the sleep expires. + + + + DAG workflows have an analogous feature: [Sleep + Conditions](/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions). + In a DAG, sleep conditions are declared upfront on the task definition. Use a + durable task when the decision to sleep is made dynamically at runtime. + + +Unlike a language-level sleep (e.g. `time.sleep` in Python or `setTimeout` in Node), durable sleep is guaranteed to respect the original duration across interruptions. A language-level sleep ties the wait to the local process, so if the process restarts, the sleep starts over from zero. + +For example, say you'd like to send a notification to a user after 24 hours. With `time.sleep`, if the task is interrupted after 23 hours, it will restart and sleep for 24 hours again (47 hours total). With durable sleep, Hatchet tracks the original deadline server-side, so the task will only sleep for 1 more hour on restart. + +## Using durable sleep + +Durable sleep can be used by calling the `SleepFor` method on the `DurableContext` object. This method takes a duration as an argument and will sleep for that duration. + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/index.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/index.mdx new file mode 100644 index 0000000000..454a915fa8 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/index.mdx @@ -0,0 +1,107 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; + +import { Callout, Steps } from "nextra/components"; +import DurableWorkflowDiagram from "@/components/DurableWorkflowDiagramWrapper"; + +# Understanding Durable Execution + + + Just getting started? Check out [Intro to Durable + Workflows](/essentials/durable-workflows) for a high-level overview of how + durable workflows fit together. + + +**Durable execution** refers to the ability of a function to easily recover from failures or interruptions. In Hatchet, we refer to a function with this ability as a **durable task**. Durable tasks store intermediate results in a durable event log, so they can recover without re-executing completed work. + + + + + For an in-depth look at how durable execution works, have a look at [this blog + post](https://hatchet.run/blog/durable-execution). + + +## How Durable Execution Works + + + +### Task runs and checkpoints + +As a durable task executes, each call to `SleepFor`, `WaitForEvent`, `WaitFor`, or `Memo` creates a checkpoint in the durable event log. These checkpoints record the task's progress. + +### Worker slot is freed during waits + +When a durable task enters a long wait (or sleep), the worker slot is released. The task is not consuming compute resources while waiting, unlike a regular task that holds its slot for the entire duration. + +### Task resumes from checkpoint + +If the task is interrupted or the wait completes, Hatchet replays the event log up to the last checkpoint and resumes execution from there. Completed operations are not re-executed. + + + +## How Hatchet Runs Durable Tasks + +Durable tasks run on the same worker process as regular tasks, but they consume a separate slot type so they do not compete with regular tasks for slots. When a durable task hits a wait (`SleepFor`, `WaitForEvent`, etc.), Hatchet [evicts](/concepts/durable-workflows/durable-task-execution/task-eviction) it from the worker, freeing the slot, and re-queues it when the wait completes. This prevents deadlock scenarios where durable tasks would starve children tasks for slots which are needed for the parent durable task to complete. + +Tasks that are declared as being durable (using `durable_task` instead of `task`), will receive a `DurableContext` object instead of a normal `Context`, which extends `Context` by providing some additional tools for working with durable execution features. + +## The Durable Context + +| Method | Purpose | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`SleepFor(duration)`** | Pause for a fixed duration. Respects the original sleep time on restart; if interrupted after 23 of 24 hours, only sleeps 1 more hour | +| **`WaitForEvent(key, expr)`** | Wait for an external event by key, with optional [CEL filter](https://github.com/google/cel-spec) expression on the payload | +| **`WaitFor(conditions)`** | General-purpose wait accepting any combination of sleep conditions, event conditions, or or-groups. `SleepFor` and `WaitForEvent` are convenience wrappers around this method | +| **`Memo(function)`** | Run functions whose outputs are memoized based on the input arguments | + +## When to Use Durable Tasks + +| Scenario | Why Durable? | +| --------------------------------- | ----------------------------------------------------------------------------- | +| **Dynamic fan-out** (N unknown) | Spawn children based on runtime data; wait for results without holding a slot | +| **Agentic workflows** | An agent decides what to do next, spawn subtasks, loop, or stop at runtime | +| **Long waits** (hours/days) | Worker slots are freed during waits; no wasted compute | +| **Human-in-the-loop** | Wait for approval events without holding resources | +| **Multi-step with inline pauses** | `SleepFor` and `WaitForEvent` let you express complex procedural flows | +| **Crash-resilient pipelines** | Automatically resume from checkpoints after failures | + +## Example Task + +Now that we know a bit about how Hatchet handles durable execution, let's build a task. We'll start by declaring a task that will run durably. + + + +Here, we've declared a Hatchet workflow. Now, we can add tasks to it: + + + +We've added two tasks to our workflow. The first is a normal, "ephemeral" task, which does not leverage any of Hatchet's durable features. + +Second, we've added a durable task, which we've created by using the `durable_task` method of the `Workflow`, as opposed to the `task` method. + + + Note that the `durable_task` we've defined takes a `DurableContext`, as + opposed to a regular `Context`, as its second argument. The `DurableContext` + is a subclass of the regular `Context` that adds some additional methods for + working with durable tasks. + + +The durable task first waits for a sleep condition. Once the sleep has completed, it continues processing until it hits the second `wait_for`. At this point, it needs to wait for an event condition. Once it receives the event, the task prints `Event received` and completes. + +If this task is interrupted at any time, it will continue from where it left off. But more importantly, if an event comes into the system while the task is waiting, the task will immediately process the event. And if the task is interrupted while in a sleeping state, it will respect the original sleep duration on restart -- that is, if the task calls `ctx.aio_sleep_for` for 24 hours and is interrupted after 23 hours, it will only sleep for 1 more hour on restart. + +### Or Groups + +Similarly to [or groups in DAG conditions](/concepts/durable-workflows/directed-acyclic-graphs/combining-conditions), durable tasks can also use or groups in the wait conditions they use. For example, you could wait for either an event or a sleep (whichever comes first) like this: + + + +## Spawning Child Tasks + +Durable tasks can spawn other tasks - including other durable tasks or entire DAG workflows - at runtime using the context object. Spawned children run independently on any available worker, and the parent can wait for their results without holding a worker slot. + +This is one of the key advantages of durable tasks over DAGs: the number and type of children can be determined dynamically based on the task's input or intermediate results. The parent is evicted during the wait, so it does not consume a slot while children execute. + +See [Spawning Children from Durable Tasks](/concepts/durable-workflows/durable-task-execution/child-spawning) for patterns and full examples. diff --git a/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/task-eviction.mdx b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/task-eviction.mdx new file mode 100644 index 0000000000..b98acd831c --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/durable-task-execution/task-eviction.mdx @@ -0,0 +1,51 @@ +import { Callout } from "nextra/components"; + +# Task Eviction + +When a durable task enters a wait — whether from `SleepFor`, `WaitForEvent`, or `WaitFor` — Hatchet **evicts** the task from the worker. The worker slot is released, the task's progress is persisted in the durable event log, and the task does not consume slots or hold resources while it is idle. + +This is what makes durable tasks fundamentally different from regular tasks: a regular task consumes a slot and holds resources for the entire duration of execution, even if it's just sleeping. A durable task gives the slot back the moment it starts waiting. + +## How eviction works + +```mermaid +graph LR + QUEUED -->|Assigned to worker| RUNNING + RUNNING -->|Hits SleepFor / WaitForEvent| EVICTED + EVICTED -->|Wait completes or event arrives| QUEUED +``` + +1. **Task reaches a wait.** The durable task calls `SleepFor`, `WaitForEvent`, or `WaitFor`. +2. **Checkpoint is written.** Hatchet records the current progress in the durable event log. +3. **Worker slot is freed.** The task is evicted from the worker. The slot is immediately available for other tasks. +4. **Wait completes.** When the sleep expires or the expected event arrives, Hatchet re-queues the task. +5. **Task resumes on any available worker.** A worker picks up the task, replays the event log to the last checkpoint, and continues execution from where it left off. + + + The resumed task does not need to run on the same worker that originally + started it. Any worker that has registered the task can pick it up. If you + need a task to resume on the same worker, use [sticky + assignment](/concepts/sticky-assignment). + + +## Why eviction matters + +Without eviction, a task that sleeps for 24 hours would consume a slot and hold resources for the entire duration — wasting capacity that could be running other work. With eviction, the slot is freed immediately and the task does not hold any resources while it waits. + +This is especially important for: + +- **Long waits** — Tasks that sleep for hours or days should not consume slots or hold resources. +- **Human-in-the-loop** — Waiting for a human to approve or respond could take minutes or weeks. Eviction ensures no slots are consumed and no resources are held in the meantime. +- **Large fan-outs** — A parent task that spawns thousands of children and waits for results can release its slot while the children run, preventing deadlocks where the parent holds resources that the children need. + +## Eviction and separate slot pools + +Durable tasks consume slots from a **separate slot pool** than regular tasks. This prevents a common deadlock: if durable and regular tasks shared the same pool, a durable task waiting on child tasks could hold the very slot those children need to execute. + +By isolating slot pools, Hatchet ensures that durable tasks waiting on children never starve the workers that need to run those children. + +## Eviction and determinism + +Because a task may be evicted and resumed on a different worker at any time, the code between checkpoints must be [deterministic](/concepts/durable-workflows/durable-task-execution/durable-best-practices). On resume, Hatchet replays the event log — it does not re-execute completed operations. If the code has changed between the original run and the replay, the checkpoint sequence may not match, leading to unexpected behavior. + +See [Best Practices](/concepts/durable-workflows/durable-task-execution/durable-best-practices) for guidelines on writing eviction-safe durable tasks. diff --git a/frontend/docs/pages/concepts/durable-workflows/index.mdx b/frontend/docs/pages/concepts/durable-workflows/index.mdx new file mode 100644 index 0000000000..46ffe4fc84 --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/index.mdx @@ -0,0 +1,67 @@ +--- +asIndexPage: true +--- + +import { Callout, Cards } from "nextra/components"; +import DurableWorkflowComparisonDiagram from "@/components/DurableWorkflowComparisonDiagram"; + +# Durable Workflows + +A **durable workflow** is work whose execution state lives in Hatchet instead of in your process. When you run a durable workflow, the orchestrator owns that state: it records progress, survives your worker crashing or scaling down, and resumes from the last checkpoint so work is not lost or duplicated. + +## Why durable? + +With ordinary tasks, "where we are" in the workflow lives in memory. If the process dies, that state is gone. With durable workflows, execution state is stored in the Hatchet event log. The orchestrator can therefore: + +- **Recover from failures** — replay from the last recorded step on another worker instead of restarting from scratch. +- **Handle long waits** — release the worker slot during "wait 24 hours" or "wait for this event" steps, then resume when the wait completes. +- **Manage distributed state** — keep multi-step, branching, or long-running flows consistent and replayable across workers and restarts. + +Your code describes the steps; Hatchet makes them durable and resumable. + +## Two patterns + +Hatchet supports two patterns for building durable workflows, and you can [mix them](/concepts/durable-workflows/mixing-patterns) within the same application. Both are durable — the difference is how you express the work. The key difference is whether you know the **shape of work** ahead of time. + + + +**Durable task execution** — The shape of work is **dynamic**. A single long-running function that can pause for time or external signals (`SleepFor`, `WaitForEvent`) and [spawn child tasks](/concepts/durable-workflows/durable-task-execution/child-spawning) at runtime. Use durable tasks when: + +- The work is IO-bound — waiting for time to pass, external events, or human approval +- The number of subtasks is determined at runtime (dynamic fan-out) +- You need procedural control flow — loops, branches, or agent-style reasoning + +**Directed acyclic graphs (DAGs)** — The shape of work is **known upfront**. You declare which tasks run, in what order, and what depends on what. Hatchet handles execution, parallelism, and retries within that fixed structure. Use DAGs when: + +- You have a well-defined pipeline (ETL, multi-step data processing) +- Every task and dependency is known before the workflow starts +- You want the full graph visible in the dashboard for debugging and monitoring + +## Choosing a pattern + +DAGs are **easier to visualize and reason about** — every task, dependency, and data flow is visible as a graph. Durable tasks offer **more flexibility** — they can branch, loop, and spawn children dynamically — but their runtime behavior is harder to predict from the code alone. When in doubt, start with a DAG and reach for a durable task only when you need capabilities a static graph can't express. You can always [mix both patterns](/concepts/durable-workflows/mixing-patterns) in the same application. + +## How workflows relate to tasks + +A workflow is a **container of tasks**. Both standalone tasks and workflows are **runnables** — they share the same API (`run`, `run_no_wait`, `schedule`, and the other trigger methods all work identically). + + + + Checkpoints, durable context, and when to use it. + + + Multi-task workflows with dependencies and parallel execution. + + + Combine durable tasks and DAGs within the same workflow. + + diff --git a/frontend/docs/pages/concepts/durable-workflows/mixing-patterns.mdx b/frontend/docs/pages/concepts/durable-workflows/mixing-patterns.mdx new file mode 100644 index 0000000000..fab686916d --- /dev/null +++ b/frontend/docs/pages/concepts/durable-workflows/mixing-patterns.mdx @@ -0,0 +1,76 @@ +import { Callout } from "nextra/components"; + +# Mixing Patterns + +Durable tasks and DAGs are not mutually exclusive - you can combine them within the same application and even within the same workflow. The general rule: use a **DAG** for any portion of work whose shape you know upfront, and use a **durable task** to orchestrate the parts whose shape is dynamic. Nest them freely. + +## A durable task inside a DAG + +A DAG workflow can include a durable task as one of its nodes. The durable task checkpoints and waits like any other, while the rest of the DAG proceeds according to its declared dependencies. + +This is useful when most of your pipeline is a fixed graph but one step needs dynamic behavior - for example, a pipeline where one stage runs an agentic loop that decides what to do at runtime. + +```mermaid +graph LR + A[Prepare Data] --> B[Durable: Agentic Loop] + B --> C[Publish Results] + style B stroke:#f59e0b,stroke-dasharray: 5 5 +``` + +The durable task (`Agentic Loop`) can spawn children, sleep, wait for events, or loop until a condition is met - while the rest of the DAG structure is declared normally. When the durable task completes, the downstream `Publish Results` task runs automatically. + +## Spawning a DAG from a durable task + +A durable task can spawn an entire DAG workflow as a child, wait for its result, and then continue. This lets you use procedural control flow to decide _which_ pipeline to run and _how many times_ to run it, while the pipeline itself is a well-defined graph. + +```mermaid +graph TD + DT[Durable Task] -->|spawns| DAG1[DAG: Process Batch 1] + DT -->|spawns| DAG2[DAG: Process Batch 2] + DT -->|spawns| DAG3[DAG: Process Batch N] + DAG1 -->|result| DT + DAG2 -->|result| DT + DAG3 -->|result| DT + style DT stroke:#6366f1 + style DAG1 stroke:#10b981 + style DAG2 stroke:#10b981 + style DAG3 stroke:#10b981 +``` + +The durable task decides at runtime how many batches to process, spawns a DAG workflow for each one, and collects the results. The DAG workflows run in parallel across your worker fleet while the durable task's slot is freed. + +## Durable tasks spawning durable tasks + +A durable task can spawn other durable tasks as children, each with their own checkpoints and event waits. This creates a tree of durable work that's entirely driven by runtime logic. + +```mermaid +graph TD + Root[Durable: Orchestrator] -->|spawns| A[Durable: Agent A] + Root -->|spawns| B[Durable: Agent B] + A -->|spawns| A1[Task: Subtask] + A -->|spawns| A2[Task: Subtask] + B -->|spawns| B1[Durable: Sub-Agent] + B1 -->|spawns| B1a[Task: Subtask] + style Root stroke:#6366f1,stroke-dasharray: 5 5 + style A stroke:#6366f1,stroke-dasharray: 5 5 + style B stroke:#6366f1,stroke-dasharray: 5 5 + style B1 stroke:#6366f1,stroke-dasharray: 5 5 +``` + +This pattern is ideal for agent-based systems where each level of the tree decides what to do next. Each durable task in the tree can sleep, wait for events, or spawn more children - and none of them hold a worker slot while waiting. + +## Choosing a pattern + +| Scenario | Pattern | +| ---------------------------------------------- | -------------------------------------------- | +| Fixed pipeline, every step is known | DAG | +| Fixed pipeline, but one step needs a long wait | DAG with a durable task node | +| Dynamic orchestration of known pipelines | Durable task spawning DAGs | +| Fully dynamic, shape decided at runtime | Durable task spawning tasks/durable tasks | +| Agent that reasons and acts in a loop | Durable task spawning children per iteration | + + + You don't have to choose one pattern for your entire application. Different + workflows can use different patterns, and a single workflow can mix them. + Start with the simplest pattern that fits and add complexity only when needed. + diff --git a/frontend/docs/pages/home/run-on-event.mdx b/frontend/docs/pages/concepts/event-filters.mdx similarity index 58% rename from frontend/docs/pages/home/run-on-event.mdx rename to frontend/docs/pages/concepts/event-filters.mdx index 6c21f01821..47d489e18a 100644 --- a/frontend/docs/pages/home/run-on-event.mdx +++ b/frontend/docs/pages/concepts/event-filters.mdx @@ -3,80 +3,15 @@ import { Snippet } from "@/components/code"; import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; import UniversalTabs from "@/components/UniversalTabs"; -# Run on Event +# Event Filters -> This example assumes we have a [task](./your-first-task.mdx) registered on a running [worker](./workers.mdx). +Events can be _filtered_ in Hatchet, which allows you to push events to Hatchet and only trigger task runs from them in certain cases. **If you enable filters on a workflow, your workflow will be triggered once for each matching filter on any incoming event with a matching scope** (more on scopes below). -Run-on-event allows you to trigger one or more tasks when a specific event occurs. This is useful when you need to execute a task in response to an ephemeral event where the result is not important. A few common use cases for event-triggered task runs are: - -1. Running a task when an ephemeral event is received, such as a webhook or a message from a queue. -2. When you want to run multiple independent tasks in response to a single event. For instance, if you wanted to run a `send_welcome_email` task, and you also wanted to run a `grant_new_user_credits` task, and a `reward_referral` task, all triggered by the signup. In this case, you might declare all three of those tasks with an event trigger for `user:signup`, and then have them all kick off when that event happens. - - - Event triggers evaluate tasks to run at the time of the event. If an event is - received before the task is registered, the task will not be run. - - -## Declaring Event Triggers - -To run a task on an event, you need to declare the event that will trigger the task. This is done by declaring the `on_events` property in the task declaration. - - - - - - - - - - - - - - - - - - Note: Multiple tasks can be triggered by the same event. - - - - As of engine version 0.65.0, Hatchet supports wildcard event triggers using - the `*` wildcard pattern. For example, you could register `subscription:*` as - your event key, which would match incoming events like `subcription:create`, - `subscription:renew`, `subscription:cancel`, and so on. - - -### Pushing an Event - -You can push an event to the event queue by calling the `push` method on the Hatchet event client and providing the event name and payload. - - - - - - - - - - - - - - - - -## Event Filtering - -Events can also be _filtered_ in Hatchet, which allows you to push events to Hatchet and only trigger task runs from them in certain cases. **If you enable filters on a workflow, your workflow will be triggered once for each matching filter on any incoming event with a matching scope** (more on scopes below). - -### Basic Usage +## Basic Usage There are two ways to create filters in Hatchet. -#### Default filters on the workflow +### Default filters on the workflow The simplest way to create a filter is to register it declaratively with your workflow when it's created. For example: @@ -97,7 +32,7 @@ The simplest way to create a filter is to register it declaratively with your wo In each of these cases, we register a filter with the workflow. Note that these "declarative" filters are overwritten each time your workflow is updated, so the ids associated with them will not be stable over time. This allows you to modify a filter in-place or remove a filter, and not need to manually delete it over the API. -#### Filters feature client +### Filters feature client You also can create event filters by using the `filters` clients on the SDKs: @@ -163,7 +98,7 @@ But this one will be triggered since the payload _does_ match the expression: filter to determine which tasks to trigger. -### Accessing the filter payload +## Accessing the filter payload You can access the filter payload by using the `Context` in the task that was triggered by your event: @@ -184,7 +119,7 @@ You can access the filter payload by using the `Context` in the task that was tr -### Advanced Usage +## Advanced Usage In addition to referencing `input` in the expression (which corresponds to the _event_ payload), you can also reference the following fields: diff --git a/frontend/docs/pages/home/inter-service-triggering.mdx b/frontend/docs/pages/concepts/inter-service-triggering.mdx similarity index 100% rename from frontend/docs/pages/home/inter-service-triggering.mdx rename to frontend/docs/pages/concepts/inter-service-triggering.mdx diff --git a/frontend/docs/pages/home/logging.mdx b/frontend/docs/pages/concepts/logging.mdx similarity index 100% rename from frontend/docs/pages/home/logging.mdx rename to frontend/docs/pages/concepts/logging.mdx diff --git a/frontend/docs/pages/home/manual-slot-release.mdx b/frontend/docs/pages/concepts/manual-slot-release.mdx similarity index 100% rename from frontend/docs/pages/home/manual-slot-release.mdx rename to frontend/docs/pages/concepts/manual-slot-release.mdx diff --git a/frontend/docs/pages/home/opentelemetry.mdx b/frontend/docs/pages/concepts/opentelemetry.mdx similarity index 100% rename from frontend/docs/pages/home/opentelemetry.mdx rename to frontend/docs/pages/concepts/opentelemetry.mdx diff --git a/frontend/docs/pages/home/priority.mdx b/frontend/docs/pages/concepts/priority.mdx similarity index 100% rename from frontend/docs/pages/home/priority.mdx rename to frontend/docs/pages/concepts/priority.mdx diff --git a/frontend/docs/pages/home/prometheus-metrics.mdx b/frontend/docs/pages/concepts/prometheus-metrics.mdx similarity index 100% rename from frontend/docs/pages/home/prometheus-metrics.mdx rename to frontend/docs/pages/concepts/prometheus-metrics.mdx diff --git a/frontend/docs/pages/concepts/pushing-events.mdx b/frontend/docs/pages/concepts/pushing-events.mdx new file mode 100644 index 0000000000..cbacf2d375 --- /dev/null +++ b/frontend/docs/pages/concepts/pushing-events.mdx @@ -0,0 +1,28 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Pushing Events + +You can push an event to Hatchet by calling the `push` method on the Hatchet event client and providing the event name and payload. Any tasks that have registered an [event trigger](/concepts/run-on-event) for that event key will be run. + + + + + + + + + + + + + + + + + + Event triggers evaluate tasks to run at the time of the event. If an event is + received before the task is registered, the task will not be run. + diff --git a/frontend/docs/pages/home/rate-limits.mdx b/frontend/docs/pages/concepts/rate-limits.mdx similarity index 100% rename from frontend/docs/pages/home/rate-limits.mdx rename to frontend/docs/pages/concepts/rate-limits.mdx diff --git a/frontend/docs/pages/home/retry-policies.mdx b/frontend/docs/pages/concepts/retry-policies.mdx similarity index 100% rename from frontend/docs/pages/home/retry-policies.mdx rename to frontend/docs/pages/concepts/retry-policies.mdx diff --git a/frontend/docs/pages/home/run-no-wait.mdx b/frontend/docs/pages/concepts/run-no-wait.mdx similarity index 97% rename from frontend/docs/pages/home/run-no-wait.mdx rename to frontend/docs/pages/concepts/run-no-wait.mdx index 564a0d1110..6af4120165 100644 --- a/frontend/docs/pages/home/run-no-wait.mdx +++ b/frontend/docs/pages/concepts/run-no-wait.mdx @@ -5,7 +5,7 @@ import UniversalTabs from "@/components/UniversalTabs"; # Enqueuing a Task Run (Fire and Forget) -> This example assumes we have a [task](./your-first-task.mdx) registered on a running [worker](./workers.mdx). +> This example assumes we have a [task](/essentials/your-first-task) registered on a running [worker](/essentials/workers). Another method of triggering a task in Hatchet is to _enqueue_ the task without waiting for it to complete, sometimes known as "fire and forget". This pattern is useful for tasks that take a long time to complete or are not critical to the immediate operation of your application. diff --git a/frontend/docs/pages/concepts/run-on-event.mdx b/frontend/docs/pages/concepts/run-on-event.mdx new file mode 100644 index 0000000000..6aaa27eb61 --- /dev/null +++ b/frontend/docs/pages/concepts/run-on-event.mdx @@ -0,0 +1,50 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Event Trigger + +> This example assumes we have a [task](/essentials/your-first-task) registered on a running [worker](/essentials/workers). + +Run-on-event allows you to trigger one or more tasks when a specific event occurs. This is useful when you need to execute a task in response to an ephemeral event where the result is not important. A few common use cases for event-triggered task runs are: + +1. Running a task when an ephemeral event is received, such as a webhook or a message from a queue. +2. When you want to run multiple independent tasks in response to a single event. For instance, if you wanted to run a `send_welcome_email` task, and you also wanted to run a `grant_new_user_credits` task, and a `reward_referral` task, all triggered by the signup. In this case, you might declare all three of those tasks with an event trigger for `user:signup`, and then have them all kick off when that event happens. + + + Event triggers evaluate tasks to run at the time of the event. If an event is + received before the task is registered, the task will not be run. + + +## Declaring Event Triggers + +To run a task on an event, you need to declare the event that will trigger the task. This is done by declaring the `on_events` property in the task declaration. + + + + + + + + + + + + + + + + + + Note: Multiple tasks can be triggered by the same event. + + + + As of engine version 0.65.0, Hatchet supports wildcard event triggers using + the `*` wildcard pattern. For example, you could register `subscription:*` as + your event key, which would match incoming events like `subcription:create`, + `subscription:renew`, `subscription:cancel`, and so on. + diff --git a/frontend/docs/pages/home/run-with-results.mdx b/frontend/docs/pages/concepts/run-with-results.mdx similarity index 96% rename from frontend/docs/pages/home/run-with-results.mdx rename to frontend/docs/pages/concepts/run-with-results.mdx index e3618909e9..c3f7f46f89 100644 --- a/frontend/docs/pages/home/run-with-results.mdx +++ b/frontend/docs/pages/concepts/run-with-results.mdx @@ -5,7 +5,7 @@ import UniversalTabs from "@/components/UniversalTabs"; # Running with Results -> This example assumes we have a [task](./your-first-task.mdx) registered on a running [worker](./workers.mdx). +> This example assumes we have a [task](/essentials/your-first-task) registered on a running [worker](/essentials/workers). One method for running a task in Hatchet is to run it and wait for its result. Some example use cases for this type of task trigger include: @@ -109,5 +109,5 @@ You can run multiple tasks in parallel by calling `Run` multiple times in gorout While you can run multiple tasks in parallel using the `Run` method, this is not recommended for large numbers of tasks. Instead, we recommend using [bulk - run methods](./bulk-run.mdx) for large parallel task execution. + run methods](/concepts/bulk-run) for large parallel task execution. diff --git a/frontend/docs/pages/home/scheduled-runs.mdx b/frontend/docs/pages/concepts/scheduled-runs.mdx similarity index 93% rename from frontend/docs/pages/home/scheduled-runs.mdx rename to frontend/docs/pages/concepts/scheduled-runs.mdx index 7b7fa505e0..1a81f8d168 100644 --- a/frontend/docs/pages/home/scheduled-runs.mdx +++ b/frontend/docs/pages/concepts/scheduled-runs.mdx @@ -5,7 +5,7 @@ import UniversalTabs from "@/components/UniversalTabs"; # Scheduled Runs -> This example assumes we have a [task](./your-first-task.mdx) registered on a running [worker](./workers.mdx). +> This example assumes we have a [task](/essentials/your-first-task) registered on a running [worker](/essentials/workers). Scheduled runs allow you to trigger a task at a specific time in the future. Some example use cases of scheduling runs might include: @@ -15,8 +15,8 @@ Scheduled runs allow you to trigger a task at a specific time in the future. Som Hatchet supports scheduled runs to run on a schedule defined in a few different ways: -- [Programmatically](./scheduled-runs.mdx#programmatically-creating-scheduled-runs): Use the Hatchet SDKs to dynamically set the schedule of a task. -- [Hatchet Dashboard](./scheduled-runs.mdx#managing-scheduled-runs-in-the-hatchet-dashboard): Manually create scheduled runs from the Hatchet Dashboard. +- [Programmatically](/concepts/scheduled-runs#programmatically-creating-scheduled-runs): Use the Hatchet SDKs to dynamically set the schedule of a task. +- [Hatchet Dashboard](/concepts/scheduled-runs#managing-scheduled-runs-in-the-hatchet-dashboard): Manually create scheduled runs from the Hatchet Dashboard. The scheduled time is when Hatchet **enqueues** the task, not when the run @@ -178,4 +178,4 @@ When using scheduled runs, there are a few considerations to keep in mind: 3. **Missed Schedules**: If a scheduled task is missed (e.g., due to system downtime), Hatchet will not automatically run the missed instances when the service comes back online. -4. **Overlapping Schedules**: If a task is still running when a second scheduled run is scheduled to start, Hatchet will start a new instance of the task or respect [concurrency](./concurrency.mdx) policy. +4. **Overlapping Schedules**: If a task is still running when a second scheduled run is scheduled to start, Hatchet will start a new instance of the task or respect [concurrency](/concepts/concurrency) policy. diff --git a/frontend/docs/pages/home/sticky-assignment.mdx b/frontend/docs/pages/concepts/sticky-assignment.mdx similarity index 100% rename from frontend/docs/pages/home/sticky-assignment.mdx rename to frontend/docs/pages/concepts/sticky-assignment.mdx diff --git a/frontend/docs/pages/home/streaming.mdx b/frontend/docs/pages/concepts/streaming.mdx similarity index 97% rename from frontend/docs/pages/home/streaming.mdx rename to frontend/docs/pages/concepts/streaming.mdx index ca5715e17c..57fa5e021b 100644 --- a/frontend/docs/pages/home/streaming.mdx +++ b/frontend/docs/pages/concepts/streaming.mdx @@ -36,7 +36,7 @@ This task will stream small chunks of content through Hatchet, which can then be ## Consuming Streams -You can easily consume stream events by using the stream method on the workflow run ref that the various [fire-and-forget](./run-no-wait.mdx) methods return. +You can easily consume stream events by using the stream method on the workflow run ref that the various [fire-and-forget](/concepts/run-no-wait) methods return. diff --git a/frontend/docs/pages/concepts/tasks.mdx b/frontend/docs/pages/concepts/tasks.mdx new file mode 100644 index 0000000000..ac6975434a --- /dev/null +++ b/frontend/docs/pages/concepts/tasks.mdx @@ -0,0 +1,107 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Tasks + + + Just getting started? Check out the [essentials + guide](/essentials/your-first-task) to declare and run your first task. + + +Everything you run in Hatchet is a **task** - a named function that you can trigger, retry, schedule, and observe. Tasks can be configured to handle the problems that come up in real systems: transient failures, resource contention, overloaded downstream services, and more. + +## Defining a task + +At minimum, a task needs a name and a function. The returned object is a **runnable** - you'll use it directly to [trigger](/concepts/run-with-results) the task. + + + + + + + + + + + + + + + + +When you define a task, you are telling Hatchet: "here is a piece of work that a worker can pick up." The task carries a name, the function to run, and optional configuration. Tasks are registered on [workers](/essentials/workers), which are the long-running processes that actually execute them. + +## Task lifecycle + +When you trigger a task, it moves through three phases: queued, running, and a terminal state. + +```mermaid +graph LR + Triggered --> QUEUED + QUEUED -->|Assigned to worker| RUNNING + RUNNING -->|Success| COMPLETED + RUNNING -->|Failure + retries left| QUEUED + RUNNING -->|Failure + no retries| FAILED +``` + +A task can also be **CANCELLED** at any point - either explicitly or by a [timeout](/concepts/timeouts) expiring. + +## Triggering a task + +The runnable returned by a task definition supports several trigger methods: + +| Method | What it does | +| ------------------------------------ | ------------------------------------------------------- | +| [Run](/concepts/run-with-results) | Trigger the task and wait for the result. | +| [Run no wait](/concepts/run-no-wait) | Enqueue the task and return immediately. | +| [Schedule](/concepts/scheduled-runs) | Schedule the task to run at a specific time. | +| [Cron](/concepts/cron-runs) | Run the task on a recurring schedule. | +| [Bulk run](/concepts/bulk-run) | Trigger many instances of the task at once. | +| [On event](/concepts/run-on-event) | Trigger the task automatically when an event is pushed. | +| [Webhook](/concepts/webhooks) | Trigger the task from an external HTTP request. | + +## Configuring a task + +Tasks can be configured to handle common problems in distributed systems. For example, you might want to automatically retry a task when an external API returns a transient error, or limit how many instances of a task run at the same time to avoid overwhelming a downstream service. + +| Concept | What it does | +| -------------------------------------------- | ---------------------------------------------------------- | +| [Retries](/concepts/retry-policies) | Retry the task on failure, with optional backoff. | +| [Timeouts](/concepts/timeouts) | Limit how long a task may wait to be scheduled or to run. | +| [Concurrency](/concepts/concurrency) | Limit how many runs of this task execute at once. | +| [Rate limits](/concepts/rate-limits) | Throttle task execution over a time window. | +| [Priority](/concepts/priority) | Influence scheduling order relative to other queued tasks. | +| [Worker affinity](/concepts/worker-affinity) | Prefer or require specific workers for this task. | + +## Input and output + +Every task receives an **input** - a JSON-serializable object passed when the task is triggered. The value you return from the task function becomes the task's **output**, which callers receive when they await the result. + +When a task is part of a [workflow](/concepts/durable-workflows), its output is also available to downstream tasks through the context object, so data flows naturally from one step to the next. See [Accessing Parent Task Outputs](/concepts/durable-workflows/directed-acyclic-graphs#accessing-parent-task-outputs) for details. + +## The context object + +Every task function receives a **context** alongside its input. The context is your handle to the Hatchet runtime while the task is executing. Through it you can perform various operations: + +- **Runtime information** like the task's run ID, workflow ID, and more. +- **Check for cancellation** and respond to it gracefully ([Cancellation](/concepts/cancellation)). +- **Refresh timeouts** if a long-running operation needs more time ([Timeouts](/concepts/timeouts)). +- **Release a worker slot** early to free capacity for other tasks ([Manual Slot Release](/concepts/manual-slot-release)). + +## How tasks execute on workers + +Tasks don't run on their own - they are assigned to and executed by [workers](/essentials/workers). A worker is a long-running process in your infrastructure that registers one or more tasks with Hatchet. When a task is triggered, Hatchet places it in a queue and assigns it to an available worker that has registered that task. + +Each worker has a fixed number of **slots** that determine how many tasks it can run concurrently. When all slots are occupied, new tasks stay queued until a slot opens up. You can control this behavior further with [concurrency limits](/concepts/concurrency), [rate limits](/concepts/rate-limits), and [priority](/concepts/priority). + +If you need tasks to run on specific workers - for example, because a worker has a GPU or a particular model loaded in memory - you can use [worker affinity](/concepts/worker-affinity) or [sticky assignment](/concepts/sticky-assignment) to influence where tasks are placed. + +## Tasks vs. workflows + +A task on its own is a standalone runnable - you can trigger it, wait for its result, schedule it, or fire it off without waiting. When you need to coordinate multiple tasks together (run B after A, fan out across N inputs, etc.), you compose them into a [workflow](/concepts/durable-workflows). Both share the same trigger interface - the difference is scope. A task does one thing; a workflow orchestrates many things. + +Next, read about how tasks compose into [workflows](/concepts/durable-workflows). diff --git a/frontend/docs/pages/home/timeouts.mdx b/frontend/docs/pages/concepts/timeouts.mdx similarity index 100% rename from frontend/docs/pages/home/timeouts.mdx rename to frontend/docs/pages/concepts/timeouts.mdx diff --git a/frontend/docs/pages/home/webhooks.mdx b/frontend/docs/pages/concepts/webhooks.mdx similarity index 99% rename from frontend/docs/pages/home/webhooks.mdx rename to frontend/docs/pages/concepts/webhooks.mdx index 1e5bfb3455..37eca24180 100644 --- a/frontend/docs/pages/home/webhooks.mdx +++ b/frontend/docs/pages/concepts/webhooks.mdx @@ -86,4 +86,4 @@ While you're creating your webhook (and also after you've created it), you can c Once you've done that, the last thing to do is register the event keys you want your workers to listen for so that they can be triggered by incoming webhooks. -For examples on how to do this, see the [documentation on event triggers](./run-on-event.mdx). +For examples on how to do this, see the [documentation on event triggers](/concepts/run-on-event). diff --git a/frontend/docs/pages/home/worker-affinity.mdx b/frontend/docs/pages/concepts/worker-affinity.mdx similarity index 100% rename from frontend/docs/pages/home/worker-affinity.mdx rename to frontend/docs/pages/concepts/worker-affinity.mdx diff --git a/frontend/docs/pages/home/worker-healthchecks.mdx b/frontend/docs/pages/concepts/worker-healthchecks.mdx similarity index 100% rename from frontend/docs/pages/home/worker-healthchecks.mdx rename to frontend/docs/pages/concepts/worker-healthchecks.mdx diff --git a/frontend/docs/pages/concepts/workers.mdx b/frontend/docs/pages/concepts/workers.mdx new file mode 100644 index 0000000000..0f37c318a9 --- /dev/null +++ b/frontend/docs/pages/concepts/workers.mdx @@ -0,0 +1,111 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Workers + + + Just getting started? Check out the [essentials guide](/essentials/workers) to + declare and start your first worker. + + +Workers are the processes that actually execute your [tasks](/concepts/tasks). Each worker is a long-running process in your infrastructure that maintains a persistent gRPC connection to the Hatchet engine. Workers receive task assignments, run your code, and report results back. You can run them locally during development, in containers, or on VMs - and scale them independently from the rest of your stack. + +## Declaring a worker + +A worker needs a name and a set of tasks to handle. Call the `worker` method on the Hatchet client with both. + + + + + + + + + + + + + + + + +When a worker starts, it registers each of its tasks with the Hatchet engine. From that point on, Hatchet knows to route matching tasks to that worker. Multiple workers can register the same task - Hatchet distributes work across all of them. + +## Worker lifecycle + +A worker moves through four phases during its lifetime: + +```mermaid +graph LR + Created -->|Connects to engine| ACTIVE + ACTIVE -->|Heartbeat timeout| INACTIVE + ACTIVE -->|Graceful shutdown| STOPPED + INACTIVE -->|Reconnects| ACTIVE +``` + +- **ACTIVE** - the worker is connected and accepting tasks. +- **INACTIVE** - the engine has not received a heartbeat within the expected window. Tasks assigned to this worker will be reassigned. +- **STOPPED** - the worker shut down gracefully. In-flight tasks are allowed to complete before the process exits. + +Hatchet uses heartbeats to monitor worker health. Workers send a heartbeat every **4 seconds**. If the engine does not receive a heartbeat for **30 seconds**, the worker is marked INACTIVE and its in-flight tasks are re-queued for other workers to pick up. + +Common reasons a worker misses heartbeats: + +- **Process crash** - the worker process exits unexpectedly (OOM kill, unhandled exception, SIGKILL). +- **Network disruption** - the connection between the worker and the Hatchet engine is interrupted (DNS failure, firewall change, cloud network blip). +- **Blocked main thread** - a long-running synchronous computation (e.g. CPU-intensive work, a blocking FFI call) starves the heartbeat loop and prevents it from sending on time. + +## Slots + +Every worker has a fixed number of **slots** that control how many tasks it can run concurrently. You configure them with the `slots` option on the worker. If you set `slots=5`, the worker will run up to five tasks at the same time. Any additional tasks wait in the queue until a slot opens up. + +Slots are a **local** limit - they protect the individual worker process from overcommitting its CPU, memory, or event loop. [Concurrency controls](/concepts/concurrency) are a **global** limit across your entire fleet - use them to prevent a single tenant or use-case from monopolizing capacity, or to respect the limits of an external resource like a third-party API or database connection pool. The two work together: concurrency controls decide how many runs Hatchet will allow to be active; slots decide how many of those runs each individual worker is willing to accept. + +### Choosing a slot count + +Start with a slot count that matches the degree of parallelism your worker can sustain. For CPU-heavy tasks, that is typically the number of available cores. For I/O-heavy tasks (HTTP calls, database queries), you can safely go higher because most of the time is spent waiting. + + + Adding slots is only helpful up to the point where the worker is not + bottlenecked by another resource. If your worker is CPU-bound, memory-bound, + or waiting on network I/O, more slots will just increase contention. Monitor + memory usage and event loop lag after changing slot counts - if either climbs, + you have gone too far. + + +## Scaling workers + +You can increase throughput in two ways: add more slots to a single worker, or run more worker processes. In most workloads, horizontal scaling (more workers) is the simplest path because each worker brings its own pool of slots and its own resources. + +When running in Kubernetes or a similar orchestrator, you can autoscale workers based on queue depth using the [Task Stats API](/concepts/autoscaling-workers). Hatchet also supports [KEDA integration](/concepts/autoscaling-workers#autoscaling-with-keda) for event-driven autoscaling. + +## Task assignment + +By default, Hatchet distributes tasks to any available worker that has registered the task. You can influence this behavior in several ways: + +| Concept | What it does | +| ---------------------------------------------------- | --------------------------------------------------------------- | +| [Worker Affinity](/concepts/worker-affinity) | Prefer or require specific workers based on labels and weights. | +| [Sticky Assignment](/concepts/sticky-assignment) | Pin related tasks in a workflow to the same worker. | +| [Manual Slot Release](/concepts/manual-slot-release) | Free a worker slot before the task function returns. | + +These are useful when a worker has specialized hardware (a GPU, a loaded ML model), or when co-locating related tasks on the same worker avoids redundant setup. + +## Running in production + +In development, the fastest way to run a worker is `hatchet worker dev`, which handles authentication and hot-reloads your code on changes. In production, you'll run workers as standalone processes or containers. + +| Concept | What it does | +| ----------------------------------------------------- | ----------------------------------------------------------------------- | +| [Running with Docker](/concepts/docker) | Containerize workers for deployment. | +| [Autoscaling Workers](/concepts/autoscaling-workers) | Scale workers dynamically based on queue depth. | +| [Worker Health Checks](/concepts/worker-healthchecks) | Expose `/health` and `/metrics` endpoints for monitoring. | +| [Preparing for Production](/essentials/production) | Operational best practices for monitoring, error handling, and scaling. | + +## Workers and tasks + +Workers and tasks have a many-to-many relationship. A single worker can register many tasks, and a single task can be registered on many workers. This means you can organize your workers by resource requirements, deployment boundary, or any other criterion - and Hatchet handles routing tasks to the right place. + +If you haven't already, read about [tasks](/concepts/tasks) to understand how work is defined and configured. diff --git a/frontend/docs/pages/essentials/_meta.js b/frontend/docs/pages/essentials/_meta.js new file mode 100644 index 0000000000..7e672fdc44 --- /dev/null +++ b/frontend/docs/pages/essentials/_meta.js @@ -0,0 +1,42 @@ +export default { + index: "🪓 What is Hatchet?", + "--get-started": { + title: "Get Started", + type: "separator", + }, + quickstart: "Quickstart", + advanced: { + title: "Advanced", + theme: { collapsed: true }, + }, + "using-coding-agents": "Using Coding Agents", + "--essentials": { + title: "Fundamentals", + type: "separator", + }, + "your-first-task": "Tasks", + workers: "Workers", + "running-your-task": "Running Tasks", + "intro-to-durable-workflows": "Intro to Durable Workflows", + "--production": { + title: "Preparing for Production", + type: "separator", + }, + production: "Overview", + "troubleshooting-workers": "Troubleshooting", + compute: { + title: "Managed Compute", + type: "page", + display: "hidden", + }, + "--evaluate": { + title: "Evaluating Hatchet?", + type: "separator", + }, + "architecture-and-guarantees": "Architecture & Guarantees", + "cloud-vs-oss": "Cloud vs OSS", + security: "Security", + "region-availability": "Region Availability", + uptime: "Uptime", + "developer-experience": "Developer Experience", +}; diff --git a/frontend/docs/pages/essentials/advanced/_meta.js b/frontend/docs/pages/essentials/advanced/_meta.js new file mode 100644 index 0000000000..cbe845ac55 --- /dev/null +++ b/frontend/docs/pages/essentials/advanced/_meta.js @@ -0,0 +1,4 @@ +export default { + index: "Advanced Setup", + environments: "Environments", +}; diff --git a/frontend/docs/pages/home/environments.mdx b/frontend/docs/pages/essentials/advanced/environments.mdx similarity index 93% rename from frontend/docs/pages/home/environments.mdx rename to frontend/docs/pages/essentials/advanced/environments.mdx index f557a73727..654ac72766 100644 --- a/frontend/docs/pages/home/environments.mdx +++ b/frontend/docs/pages/essentials/advanced/environments.mdx @@ -19,4 +19,4 @@ The easiest way to isolate environments for different developers or teams is to ### Solution 2: Local Hatchet Instance -If you are using Hatchet locally, you can create a local instance of Hatchet to manage your isolated local development environment. Follow instructions [here](../self-hosting/hatchet-lite.mdx) to get started. +If you are using Hatchet locally, you can create a local instance of Hatchet to manage your isolated local development environment. Follow instructions [here](/self-hosting/hatchet-lite) to get started. diff --git a/frontend/docs/pages/home/setup.mdx b/frontend/docs/pages/essentials/advanced/index.mdx similarity index 87% rename from frontend/docs/pages/home/setup.mdx rename to frontend/docs/pages/essentials/advanced/index.mdx index ab6973c960..d040e2a8b4 100644 --- a/frontend/docs/pages/home/setup.mdx +++ b/frontend/docs/pages/essentials/advanced/index.mdx @@ -1,4 +1,4 @@ -import Tabs from "../_setup/tabs.mdx"; +import Tabs from "../../_setup/tabs.mdx"; import { Callout } from "nextra/components"; # Advanced Setup @@ -6,8 +6,8 @@ import { Callout } from "nextra/components"; This guide is intended for users who want to explore Hatchet in more depth beyond the quickstart. If you haven't already set up Hatchet, please see the - [Hatchet Cloud Quickstart](./hatchet-cloud-quickstart) or the [Self-Hosting - Quickstart](../../self-hosting/) first. + [Hatchet Cloud Quickstart](/essentials/quickstart) or the [Self-Hosting + Quickstart](/self-hosting) first. ## Set environment variables @@ -28,4 +28,4 @@ export HATCHET_CLIENT_TLS_STRATEGY=none -Continue to the next section to learn how to [create your first task](./your-first-task) +Continue to the next section to learn how to [create your first task](/essentials/your-first-task) diff --git a/frontend/docs/pages/essentials/architecture-and-guarantees.mdx b/frontend/docs/pages/essentials/architecture-and-guarantees.mdx new file mode 100644 index 0000000000..40ddb84f62 --- /dev/null +++ b/frontend/docs/pages/essentials/architecture-and-guarantees.mdx @@ -0,0 +1,146 @@ +import { Callout } from "nextra/components"; + +# Architecture & Guarantees + +This page explains how Hatchet is put together, what the main components do, and what reliability guarantees you should design your workers around. + +## Architecture overview + +Hatchet has three main moving pieces: + +- **API server**: the HTTP surface area for triggering workflows, querying state, and powering the UI +- **Engine**: schedules and dispatches work, enforces dependencies/policies, and records state transitions durably +- **Workers**: your processes that run the actual task code + +State is stored durably (PostgreSQL is the source of truth). In many deployments that’s enough—no separate broker required—while self-hosted high-throughput setups can add additional components (for example, RabbitMQ) based on your needs. + +Hatchet Cloud and self-hosted Hatchet share the same architecture; the difference is who runs and operates the Hatchet services. + +```mermaid +graph LR + subgraph "External (Optional)" + EXT[Webhooks
Events] + end + + subgraph "Your Infrastructure" + APP[Your API, App, Service, etc.] + W[Workers] + end + + subgraph "Hatchet" + API[API Server] + ENG[Engine] + DB[(Database)] + end + + EXT --> API + APP <--> API + API --> ENG + ENG <--> DB + API <--> DB + ENG <-.->|gRPC| W + + classDef userInfra fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1 + classDef hatchet fill:#f1f8e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20 + classDef external fill:#fff8e1,stroke:#f57c00,stroke-width:2px,color:#e65100 + + class APP,W userInfra + class API,ENG,DB hatchet + class EXT external +``` + +## Core components + +### API server + +The API server is the front door to Hatchet. It’s what your application and the Hatchet UI talk to in order to: + +- trigger workflows with input data +- query workflow/task state (and, where supported, subscribe to updates) +- manage resources like schedules and settings +- ingest webhooks/events (optional) + +### Engine + +The engine is responsible for turning “a workflow should run” into “these tasks are ready and should be executed.” In practice, it: + +- evaluates workflow dependencies +- enforces policies like concurrency limits, rate limits, and priorities +- schedules ready tasks and dispatches them to connected workers +- records state transitions durably and applies retry/timeout behavior +- runs scheduled/cron workflows + +Workers connect to the engine over bidirectional gRPC, which allows low-latency dispatch and frequent status updates. + +### Workers + +Workers are your processes. They connect to the engine, receive tasks, run your code, and report progress/results back to Hatchet. + +Workers are intentionally flexible: you can run them locally, in containers, or on VMs, and you can scale workers independently from the Hatchet services. You can also run different “types” of workers (and even different languages) depending on what your system needs. + +### Storage (and optional messaging) + +PostgreSQL is the durable store for workflow definitions and execution state (queued/running/completed, inputs/outputs, retries, etc.). In self-hosted deployments, you can start with PostgreSQL-only and add components like RabbitMQ if you need higher throughput. + +## Guarantees & tradeoffs + +Hatchet aims to sit in the middle: more structure than a simple queue, but simpler to operate than a full distributed workflow system. + +### Good fit for + +- **Workflow orchestration** with dependencies, retries, and timeouts +- **Durable background jobs** where “don’t lose work” matters +- **Moderate to high throughput** systems (and a path to higher scale with tuning/sharding). If you’re pushing the limits, [contact us](https://hatchet.run/contact). +- **Multi-language / polyglot workers** +- **Teams already on PostgreSQL** who want operational simplicity +- **Cloud or air-gapped environments** ([Hatchet Cloud](https://cloud.onhatchet.run) or [self-hosting](/self-hosting)) + +### Not a good fit for + +- **Extremely high throughput** without sharding/custom tuning (for example, sustaining 10,000+ tasks/sec) +- **Sub-millisecond dispatch latency** requirements +- **In-memory-only queuing** where durability is unnecessary +- **Serverless-only runtimes** (e.g. AWS Lambda / Cloud Functions) as your primary worker model + +## Core reliability guarantees + +### At-least-once task execution + +Hatchet is **at least once**: tasks are not silently dropped, and failures retry according to your configuration. This also means **a task can run more than once**, so your task code should be **idempotent** (or otherwise safe to retry). + +### Durable state transitions + +Workflow state is persisted in PostgreSQL, and state transitions are performed transactionally. This helps keep dependency resolution consistent and makes the system resilient to restarts and transient failures. + +### Execution policies are explicit + +By default, task assignment is FIFO, and you can change execution behavior using: + +- [Concurrency policies](/concepts/concurrency) +- [Rate limits](/concepts/rate-limits) +- [Priorities](/concepts/priority) + +### Stateless services; resilient workers + +The engine and API server are designed to restart without losing state, which also enables horizontal scaling by running multiple instances. Workers reconnect after network interruptions and can run close to your services (or close to Hatchet) depending on your latency goals. + +## Performance expectations + +Real-world performance depends heavily on topology (worker ↔ engine network latency), database sizing, and workload shape. + +- **Dispatch latency**: often sub-50ms with PostgreSQL-backed storage; in optimized, “hot worker” setups it can be closer to ~25ms P95. +- **Throughput**: varies by setup. PostgreSQL-only deployments often handle hundreds of tasks/sec per engine instance; higher throughput typically requires additional tuning and/or components like RabbitMQ. With tuning and sharding, Hatchet can scale into the high tens of thousands of tasks/sec—[contact us](https://hatchet.run/contact) if you want to design for that. +- **Common bottlenecks**: DB connection limits, large payloads (e.g. > 1MB), complex dependency graphs, and cross-region latency. + + + +**Not seeing expected performance?** + +If you're not seeing the performance you expect, please [reach out to us](https://hatchet.run/office-hours) or [join our community](https://hatchet.run/discord) to explore tuning options. + + + +## Next Steps + +- **[Quick Start](/essentials/quickstart)**: set up your first Hatchet worker +- **[Self-Hosting](/self-hosting)**: deploy Hatchet on your own infrastructure diff --git a/frontend/docs/pages/essentials/cloud-vs-oss.mdx b/frontend/docs/pages/essentials/cloud-vs-oss.mdx new file mode 100644 index 0000000000..40ba3c7365 --- /dev/null +++ b/frontend/docs/pages/essentials/cloud-vs-oss.mdx @@ -0,0 +1,67 @@ +# Cloud vs OSS + +Hatchet is available as **Hatchet Cloud** (managed) and as **open source** (self-hosted). The programming model is the same: you write tasks/workflows in code and run workers that connect to Hatchet. + +This page helps you decide which deployment model fits your team. + +## Quick decision guide + +Choose **Hatchet Cloud** if you want: + +- the Hatchet control plane operated for you (upgrades, scaling, backups) +- the fastest path to production +- a status page and managed incident response + +Choose **self-hosted (OSS)** if you need: + +- full control over infrastructure and networking +- strict data residency or air-gapped environments +- a deployment you can customize and operate with your own tooling + +## What’s the same in both + +- **SDK + worker model**: your workers run your code and connect to Hatchet +- **Durability + retries**: tasks are durably tracked and retry according to configuration +- **Observability surfaces**: you can inspect runs, workers, and workflow history +- **Core semantics**: the same workflows/tasks/concurrency patterns apply + +## What changes (operational responsibilities) + +### Hatchet Cloud (managed) + +Hatchet runs and operates the Hatchet services. You bring: + +- your worker processes +- your application code that triggers workflows +- your operational policies (timeouts, retries, concurrency, rate limits) + +For security and compliance documentation, see the **[Hatchet Trust Center](https://trust.hatchet.run/)**. For current incidents and historical uptime, see **[status.hatchet.run](https://status.hatchet.run/)**. + +### Self-hosted (OSS) + +You run and operate the Hatchet services and their dependencies. Typical responsibilities include: + +- provisioning and scaling the Hatchet services +- managing PostgreSQL (and any optional components you deploy) +- backups, upgrades, and monitoring +- network security and access control for the API/DB + +If you’re planning production usage, start with: + +- [Self Hosting](/self-hosting) +- [High Availability](/self-hosting/high-availability) +- [Security](/essentials/security) + +## Migrating between Cloud and self-hosted + +You can move between deployment models without rewriting worker code. In practice, migration usually means: + +- pointing workers and clients at a new endpoint +- swapping credentials/tokens +- validating environment-specific settings (TLS, networking, retention, etc.) + +## Next steps + +- **[Quickstart](/essentials/quickstart)**: run a worker and trigger your first workflow +- **[Architecture & Guarantees](/essentials/architecture-and-guarantees)**: understand reliability semantics and tradeoffs +- **[Self Hosting](/self-hosting)**: deploy Hatchet on your own infrastructure diff --git a/frontend/docs/pages/home/compute/_meta.js b/frontend/docs/pages/essentials/compute/_meta.js similarity index 100% rename from frontend/docs/pages/home/compute/_meta.js rename to frontend/docs/pages/essentials/compute/_meta.js diff --git a/frontend/docs/pages/home/compute/auto-scaling.mdx b/frontend/docs/pages/essentials/compute/auto-scaling.mdx similarity index 100% rename from frontend/docs/pages/home/compute/auto-scaling.mdx rename to frontend/docs/pages/essentials/compute/auto-scaling.mdx diff --git a/frontend/docs/pages/home/compute/cpu.mdx b/frontend/docs/pages/essentials/compute/cpu.mdx similarity index 100% rename from frontend/docs/pages/home/compute/cpu.mdx rename to frontend/docs/pages/essentials/compute/cpu.mdx diff --git a/frontend/docs/pages/home/compute/environment-variables.mdx b/frontend/docs/pages/essentials/compute/environment-variables.mdx similarity index 100% rename from frontend/docs/pages/home/compute/environment-variables.mdx rename to frontend/docs/pages/essentials/compute/environment-variables.mdx diff --git a/frontend/docs/pages/home/compute/getting-started.mdx b/frontend/docs/pages/essentials/compute/getting-started.mdx similarity index 100% rename from frontend/docs/pages/home/compute/getting-started.mdx rename to frontend/docs/pages/essentials/compute/getting-started.mdx diff --git a/frontend/docs/pages/home/compute/git-ops.mdx b/frontend/docs/pages/essentials/compute/git-ops.mdx similarity index 100% rename from frontend/docs/pages/home/compute/git-ops.mdx rename to frontend/docs/pages/essentials/compute/git-ops.mdx diff --git a/frontend/docs/pages/home/compute/gpu.mdx b/frontend/docs/pages/essentials/compute/gpu.mdx similarity index 100% rename from frontend/docs/pages/home/compute/gpu.mdx rename to frontend/docs/pages/essentials/compute/gpu.mdx diff --git a/frontend/docs/pages/home/compute/index.mdx b/frontend/docs/pages/essentials/compute/index.mdx similarity index 100% rename from frontend/docs/pages/home/compute/index.mdx rename to frontend/docs/pages/essentials/compute/index.mdx diff --git a/frontend/docs/pages/essentials/developer-experience.mdx b/frontend/docs/pages/essentials/developer-experience.mdx new file mode 100644 index 0000000000..36e940af14 --- /dev/null +++ b/frontend/docs/pages/essentials/developer-experience.mdx @@ -0,0 +1,40 @@ +# Developer experience + +Hatchet is designed to be practical day-to-day: write workflows in code, run workers locally with a tight feedback loop, and debug production runs with good visibility. + +## Workflows as code + +You define tasks and workflows in your application code, then trigger them with input data. Hatchet handles the operational pieces you’d otherwise build yourself: + +- **Durability** (work isn’t lost on restarts) +- **Retries/timeouts** +- **Concurrency and rate limiting** +- **Visibility into what ran, where, and why** + +## Dashboard (UI) + +The dashboard is where you go to understand “what is happening right now?”: + +- **Runs**: status, inputs/outputs, and execution history +- **Workers**: connected workers and health +- **Workflows**: definitions and recent activity +- **Settings**: tenants, API tokens, configuration + +It’s useful for debugging, operational checks, and ad-hoc triggers. + +## CLI + +The [Hatchet CLI](/reference/cli) is the fastest way to develop and operate Hatchet from your terminal: + +- **`hatchet worker dev`**: run a local worker with hot reload +- **`hatchet trigger`**: trigger a workflow from the command line (handy for smoke tests) +- **`hatchet tui`**: terminal UI for runs/workers/workflows +- **`hatchet profile`**: switch between tenants and environments + +See the [CLI reference](/reference/cli) for installation and the full command set. + +## Coding agents (MCP) + +If you use AI coding tools in your editor, Hatchet’s docs can be used via an [MCP (Model Context Protocol) server](/essentials/using-coding-agents). We also publish “agent skills” (short, step-by-step playbooks) so coding agents can run common Hatchet workflows—like starting a worker, triggering a workflow, and debugging a run—without guessing at CLI usage. + +See [Using Coding Agents](/essentials/using-coding-agents) for setup. diff --git a/frontend/docs/pages/home/index.mdx b/frontend/docs/pages/essentials/index.mdx similarity index 56% rename from frontend/docs/pages/home/index.mdx rename to frontend/docs/pages/essentials/index.mdx index 9f08e976ba..cdb64d0560 100644 --- a/frontend/docs/pages/home/index.mdx +++ b/frontend/docs/pages/essentials/index.mdx @@ -1,36 +1,42 @@ -import { Tabs, Callout } from "nextra/components"; +--- +asIndexPage: true +--- + +import { Callout } from "nextra/components"; # What is Hatchet? Hatchet is a modern orchestration platform that helps engineering teams build low-latency and high-throughput data ingestion and agentic AI pipelines. -You write simple functions, called [tasks](./home/your-first-task), in Python, Typescript, and Go and run them on [workers](./home/workers) in your own infrastructure. You can compose these tasks into [parent/child relationships](./home/child-spawning) or predefined as [Directed Acyclic Graphs (DAGs)](./home/dags) to build more complex pipelines, which we call [workflows](./home/orchestration). All tasks and workflows are **defined as code**, making them easy to version, test, and deploy. +You write functions in Python, Typescript, Go, or Ruby and let Hatchet handle scheduling, retries, fault tolerance, and observability. The core mental model has three parts: -Hatchet handles scheduling, complex assignment, fault tolerance, and observability so you can focus on building your application as you scale. +- **[Tasks](/essentials/your-first-task)** — the fundamental unit of work. A task wraps a single function and gives Hatchet everything it needs to schedule, execute, and observe it. +- **[Workers](/essentials/workers)** — long-running processes in your infrastructure that pick up and execute tasks. +- **[Durable Workflows](/essentials/durable-workflows)** — compose multiple tasks into durable pipelines with dependencies, retries, and checkpointing. -## Patterns +All tasks and workflows are **defined as code**, making them easy to version, test, and deploy. -## Use-Cases +## Use cases While Hatchet is a general-purpose orchestration platform, it's particularly well-suited for: -- **Real-time data processing pipelines**: for example, data ingestion which is crucial for keeping LLM contexts up-to-date, or ETL pipelines that require fast execution and high throughput. -- **AI agents**: a core number of Hatchet's features, like [webhooks](./home/webhooks), [child spawning](./home/child-spawning), and [dynamic workflows](./home/child-spawning) are designed to support AI agents. -- **Event-driven systems**: Hatchet's [eventing features](./home/run-on-event) allow you to build event-driven architectures without requiring additional infrastructure. +- **Real-time data processing** — data ingestion for keeping LLM contexts up-to-date, ETL pipelines that require fast execution and high throughput. +- **AI agents** — features like [webhooks](/concepts/webhooks), [child spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning), and dynamic workflows are designed to support agentic patterns. +- **Event-driven systems** — Hatchet's [eventing features](/concepts/run-on-event) let you build event-driven architectures without additional infrastructure. ## Why Hatchet? -⚡️ **Low-Latency For Real-Time Workloads** - Sub-25ms task dispatch for hot workers with thousands of concurrent tasks. Smart assignment rules handle [rate-limits](./home/rate-limits), [fairness](./home/concurrency), and [priorities](./home/priority) without complex configuration. +⚡️ **Low-Latency For Real-Time Workloads** - Sub-25ms task dispatch for hot workers with thousands of concurrent tasks. Smart assignment rules handle [rate-limits](/concepts/rate-limits), [fairness](/concepts/concurrency), and [priorities](/concepts/priority) without complex configuration. -🪨 **Durability for Long Running Jobs** - Every task invocation is durably logged to PostgreSQL. With [durable execution](./home/durable-execution), when jobs fail your workflow will resume exactly where you left off — no lost work, no duplicate LLM calls, no engineer headaches. +🪨 **Durability for Long Running Jobs** - Every task invocation is durably logged to PostgreSQL. With [durable execution](/concepts/durable-workflows/durable-task-execution), when jobs fail your workflow will resume exactly where you left off — no lost work, no duplicate LLM calls, no engineer headaches. 🧘‍♂️ **Zen Developer Experience** - Hatchet SDKs (Python, Typescript, and Go) are built with modern tooling and are designed to be easy to use. Hatchet has built-in observability and debugging tools for things like replays, logs, and alerts. If you plan on self-hosting or have requirements for an on-premise deployment, there are some additional considerations: -🐘 **Minimal Infra Dependencies** - Hatchet is built on top of PostgreSQL and for simple workloads, [its all you need](./self-hosting/hatchet-lite.mdx). +🐘 **Minimal Infra Dependencies** - Hatchet is built on top of PostgreSQL and for simple workloads, [its all you need](/self-hosting/hatchet-lite). -⬆️ **Fully Featured Open Source** - Hatchet is 100% MIT licensed, so you can run the same application code against [Hatchet Cloud](https://cloud.onhatchet.run) to get started quickly or [self-host](./self-hosting.mdx) when you need more control. +⬆️ **Fully Featured Open Source** - Hatchet is 100% MIT licensed, so you can run the same application code against [Hatchet Cloud](https://cloud.onhatchet.run) to get started quickly or [self-host](/self-hosting) when you need more control. ## Hatchet vs. Alternatives @@ -65,25 +71,8 @@ Hatchet has been battle-tested in production environments, processing billions o > "Hatchet enables Aevy to process up to 50,000 documents in under an hour through optimized parallel execution, compared to nearly a week with our previous setup." > — Ymir, CTO @ Aevy -## Quick Starts - -We have a number of quick start tutorials for getting up and running quickly with Hatchet: - -- [Hatchet Cloud Quickstart](./hatchet-cloud-quickstart.mdx) -- [Hatchet Self-Hosted Quickstarts](./self-hosting.mdx) - -We also have guides for getting started with the Hatchet SDKs: - -- [Python SDK Quickstart](https://github.com/hatchet-dev/hatchet-python-quickstart) -- [Typescript SDK Quickstart](https://github.com/hatchet-dev/hatchet-typescript-quickstart) -- [Go SDK Quickstart](https://github.com/hatchet-dev/hatchet-go-quickstart) - -## Learn More - -Ready to dive deeper? Explore these additional resources: - -**[Architecture](./architecture.mdx)** - Learn how Hatchet is built and designed for scale. +## Ready to dive deeper? -**[Guarantees & Tradeoffs](./guarantees-and-tradeoffs.mdx)** - Understand Hatchet's guarantees, limitations, and when to use it. +Check out the **[Architecture & Guarantees](/essentials/architecture-and-guarantees)** page to learn how Hatchet is built, its guarantees, and when to use it. -Or get started with the **[Hatchet Cloud Quickstart](./hatchet-cloud-quickstart.mdx)** or **[self-hosting](./self-hosting.mdx)**. +Or get started with the **[Hatchet Cloud Quickstart](/essentials/quickstart)** or **[self-hosting](/self-hosting)**. diff --git a/frontend/docs/pages/essentials/intro-to-durable-workflows.mdx b/frontend/docs/pages/essentials/intro-to-durable-workflows.mdx new file mode 100644 index 0000000000..0052fb7d12 --- /dev/null +++ b/frontend/docs/pages/essentials/intro-to-durable-workflows.mdx @@ -0,0 +1,39 @@ +import { Cards } from "nextra/components"; +import DurableWorkflowComparisonDiagram from "@/components/DurableWorkflowComparisonDiagram"; + +# Intro to Durable Workflows + +A **durable workflow** is work whose execution state lives in Hatchet, not in your process. If a worker crashes, scales down, or deploys, Hatchet resumes from the last checkpoint instead of starting over. + +Hatchet supports two patterns. Both are durable; the difference is whether you know the **shape of work** ahead of time. + + + +**Durable Task Execution** — a single long-running function that checkpoints its progress. Use when the work is dynamic: IO waits, runtime fan-out, loops, or agent-style reasoning. + +**DAGs (Directed Acyclic Graphs)** — a set of tasks connected by declared dependencies. Use when the shape of work is known upfront: pipelines, multi-step processing, branching logic. + +## Ready to dive deeper? + +Read the [Durable Workflows concepts page](/concepts/durable-workflows) for more details on how to use durable workflows. + + + + Checkpoints, durable context, sleep, events, child spawning, and eviction. + + + Declaring workflows, task dependencies, conditions, and parallel execution. + + + Combine DAGs and durable tasks in the same workflow. + + diff --git a/frontend/docs/pages/essentials/production.mdx b/frontend/docs/pages/essentials/production.mdx new file mode 100644 index 0000000000..3f7ba73caf --- /dev/null +++ b/frontend/docs/pages/essentials/production.mdx @@ -0,0 +1,27 @@ +import { Callout } from "nextra/components"; + +# Preparing for Production + +Workers are long-lived processes, so treat them like any other production service. Give them enough CPU and memory that a spike in traffic won't starve tasks or trigger OOM kills, and tune [slots](/concepts/workers#slots) based on what you actually observe rather than what seems reasonable on paper. + +Visibility matters more than most teams expect. Include workflow and task identifiers in your logs so you can trace a failure back to a specific run without guessing. Alert on missed heartbeats and rising error rates early—by the time throughput drops noticeably, the backlog is usually already deep. The Python SDK has built-in [health check endpoints](/concepts/worker-healthchecks) you can wire into your infrastructure. + +Tasks will fail. That's fine as long as failures are reported back to Hatchet accurately and your [retry policies](/concepts/retry-policies) match the kind of failure. Transient network errors are worth retrying; a bad input that will never parse is not. Making tasks idempotent wherever you can removes a whole class of retry-related bugs. + +If you're self-hosting, secure the connection between workers and the engine with TLS. If you're on Hatchet Cloud this is handled for you. Either way, keep credentials in a secret manager and rotate them on a schedule. + +Workers should start under a process manager or container orchestrator that will restart them automatically after a crash. Equally important is shutting down gracefully—give in-flight tasks time to finish or requeue before the process exits. Test both paths before you rely on them in production. + +Finally, keep your Hatchet SDK version in step with your engine version. Roll updates out gradually and watch for regressions before promoting to the full fleet. + +## Containerizing your workers + +Most production deployments run workers inside containers. See [Running with Docker](/concepts/docker) for example Dockerfiles across all supported languages. + +## Scaling + +As demand grows, you can scale workers horizontally. Hatchet provides a [Task Stats API](/concepts/autoscaling-workers) that exposes real-time queue depths, which you can feed into autoscalers like KEDA to add or remove workers automatically. + +## Troubleshooting + +If workers are not appearing in the dashboard, tasks are failing to send, or phantom workers are showing up, see the [troubleshooting guide](/essentials/troubleshooting-workers). diff --git a/frontend/docs/pages/home/hatchet-cloud-quickstart.mdx b/frontend/docs/pages/essentials/quickstart.mdx similarity index 82% rename from frontend/docs/pages/home/hatchet-cloud-quickstart.mdx rename to frontend/docs/pages/essentials/quickstart.mdx index 5fb8405913..7708901cbb 100644 --- a/frontend/docs/pages/home/hatchet-cloud-quickstart.mdx +++ b/frontend/docs/pages/essentials/quickstart.mdx @@ -1,3 +1,7 @@ +--- +asIndexPage: true +--- + import { snippets } from "@/lib/generated/snippets"; import { Snippet } from "@/components/code"; import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; @@ -5,9 +9,13 @@ import UniversalTabs from "../../components/UniversalTabs"; # Hatchet Cloud Quickstart -Welcome to Hatchet! This guide walks you through getting set up on Hatchet Cloud. If you'd like to self-host Hatchet, please see the [self-hosted quickstart](../../self-hosting/) instead. +By the end of this guide you'll have a worker running locally that executes a simple task triggered from the CLI. -## Quickstart + + This guide walks you through getting set up on Hatchet Cloud. If you'd like to + self-host Hatchet, please see the [self-hosted quickstart](/self-hosting) + instead. + @@ -86,7 +94,7 @@ Get Hatchet documentation directly in your AI coding assistant (Cursor, Claude C hatchet docs install ``` -See the [full setup guide](./install-docs-mcp.mdx) for manual configuration options. +See the [full setup guide](/essentials/using-coding-agents) for manual configuration options. @@ -94,4 +102,4 @@ And that's it! You should now have a Hatchet project set up on Hatchet Cloud wit ## Next Steps -Once you've completed the quickstart, you can explore a more in-depth walkthrough of Hatchet using the [walkthrough](/home/setup) guide. +Once you've completed the quickstart, you can explore a more in-depth walkthrough of Hatchet using the [Advanced Setup](/essentials/advanced) guide. diff --git a/frontend/docs/pages/essentials/region-availability.mdx b/frontend/docs/pages/essentials/region-availability.mdx new file mode 100644 index 0000000000..4df12af954 --- /dev/null +++ b/frontend/docs/pages/essentials/region-availability.mdx @@ -0,0 +1,20 @@ +# Region availability + +Hatchet Cloud is available in multiple regions so you can run workloads close to your users and data. + +## Current regions + +**Hatchet Cloud** ([cloud.onhatchet.run](https://cloud.onhatchet.run)) is currently deployed in **us-west-2** (Oregon). + +We are expanding availability. Planned or available regions include: + +| Region | Location | Status | +| -------------- | ---------------- | ------------ | +| us-west-2 | Oregon (US) | **Live** | +| us-east-1 | N. Virginia (US) | Private Beta | +| eu-west-1 | Ireland | Private Beta | +| ap-southeast-2 | Sydney | Private Beta | + +## Request a region + +We are always open to rolling out new regions based on demand. If you need a specific region for latency or compliance, [contact us](https://hatchet.run/contact) and we can discuss availability. diff --git a/frontend/docs/pages/essentials/running-your-task.mdx b/frontend/docs/pages/essentials/running-your-task.mdx new file mode 100644 index 0000000000..7ba48f0358 --- /dev/null +++ b/frontend/docs/pages/essentials/running-your-task.mdx @@ -0,0 +1,57 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Running Your First Task + +With your task defined and a worker running, you can import the task wherever you need it and invoke it with the `run` method. + + + + + + + + + + + + + + + + + + +## Common trigger patterns + +The two most common ways to run a task are: + +- **[Run and wait](/concepts/run-with-results)** — call a task and block until you get the result back. Use this for synchronous workflows like fan-out, LLM calls, or any time you need the output before continuing. +- **[Fire and forget](/concepts/run-no-wait)** — enqueue a task without waiting for the result. Use this for background jobs like sending emails, processing uploads, or kicking off long-running pipelines. + +## Where you can trigger from + +You can trigger tasks in two main ways: + +- **Same codebase or monorepo** — import your task and call `run`, `run_no_wait`, or other trigger methods directly. Your API server, CLI, or another service in the same repo can use the same task definition. +- **External API or separate service (polyrepo)** — when the triggering code can't import the task definition (different repo, language, or microservice), use a **stub**: a Hatchet task with the same name and input/output types but no implementation. See [Inter-Service Triggering](/concepts/inter-service-triggering) for details. +- **From the CLI** — use the `hatchet run` command to trigger tasks from the command line. +- **From the Dashboard** — use the Hatchet dashboard to trigger tasks from the web interface. + +## Other trigger styles + +Hatchet supports additional trigger patterns for more advanced use cases: + +| Style | Use case | Doc | +| ------------- | ---------------------------------------------------- | --------------------------------------------- | +| **Scheduled** | Run once at a specific time in the future | [Scheduled Trigger](/concepts/scheduled-runs) | +| **Cron** | Run on a recurring schedule (daily, weekly, etc.) | [Cron Trigger](/concepts/cron-runs) | +| **Events** | Run when an event is emitted (e.g. webhooks, queues) | [Event Trigger](/concepts/run-on-event) | +| **Bulk** | Run the same task many times with different inputs | [Bulk Run Many](/concepts/bulk-run) | +| **Webhooks** | Let external systems trigger workflows via HTTP | [Webhooks](/concepts/webhooks) | + +## Next steps + +Now that you can run tasks, explore [Durable Workflows](/essentials/durable-workflows) to compose multiple tasks into pipelines with dependencies and checkpointing. diff --git a/frontend/docs/pages/essentials/security.mdx b/frontend/docs/pages/essentials/security.mdx new file mode 100644 index 0000000000..533b8ccc48 --- /dev/null +++ b/frontend/docs/pages/essentials/security.mdx @@ -0,0 +1,36 @@ +# Security + +This page points you to Hatchet's security resources and highlights the most important security considerations for Hatchet Cloud and self-hosted deployments. + +## Trust center + +Hatchet is SOC 2 Type II, HIPAA, and GDPR compliant. Company-level security practices, compliance reports, and security documentation are available at the **[Hatchet Trust Center](https://trust.hatchet.run/)**. + +## Same source, same security + +Hatchet Cloud and self-hosted Hatchet run the same codebase. The open source project is 100% MIT licensed and undergoes regular third-party penetration testing. Findings are remediated across both deployment models, so security improvements benefit all users equally. + +## Hatchet Cloud + +Hatchet Cloud is Hatchet's managed service: + +- **Encryption in transit**: all API and worker traffic is encrypted with TLS. gRPC connections between workers and the engine use TLS by default. +- **Encryption at rest**: data stored in Hatchet Cloud is encrypted at rest. +- **Tenant isolation**: each tenant's data is logically isolated. Requests are authenticated and scoped to a single tenant. +- **Authentication**: API tokens are scoped per-tenant with configurable expiration. The dashboard supports SSO via Google, GitHub, and more coming soon. +- **Penetration testing**: Hatchet Cloud is regularly tested by independent security firms. Findings are tracked and remediated on a defined timeline. +- **Infrastructure**: Hatchet Cloud runs on AWS with private networking, automated patching, and centralized logging. + +For the definitive controls, policies, and compliance reports, refer to the **[Hatchet Trust Center](https://trust.hatchet.run/)**. + +## Self-hosted + +When you self-host Hatchet, your security posture depends on how you deploy and operate the Hatchet services and their dependencies. A practical baseline: + +- **Put TLS in front of the API**: terminate TLS at your ingress/load balancer (or directly on the API) and only expose it to the networks that need it. +- **Treat tokens and DB credentials as secrets**: use a secrets manager and rotate credentials; avoid committing secrets into git or baking them into images. +- **Limit network reachability**: restrict access to the Hatchet API and PostgreSQL to trusted networks (VPC, private subnets, or Kubernetes network policies). +- **Use least privilege**: run Hatchet with the minimum DB permissions needed; don't reuse "admin" DB credentials. +- **Stay current**: keep Hatchet and dependencies up to date to pick up security fixes. + +See [Self Hosting](/self-hosting) for deployment and configuration guidance, or [contact us](https://hatchet.run/contact) for help. diff --git a/frontend/docs/pages/essentials/troubleshooting-workers.mdx b/frontend/docs/pages/essentials/troubleshooting-workers.mdx new file mode 100644 index 0000000000..a5b1e1d8df --- /dev/null +++ b/frontend/docs/pages/essentials/troubleshooting-workers.mdx @@ -0,0 +1,59 @@ +import { Tabs, Callout } from "nextra/components"; + +# Troubleshooting Hatchet Workers + +This guide covers common issues when deploying and operating Hatchet workers. + +## Quick debugging checklist + +Before diving into specific issues, run through these checks: + +1. **Verify your API token** — make sure `HATCHET_CLIENT_TOKEN` matches the token generated in the Hatchet dashboard for your tenant. +2. **Check worker logs** — look for connection errors, heartbeat failures, or crash traces in your worker output. +3. **Check the dashboard** — navigate to the Workers tab to see if your worker is registered and healthy. +4. **Confirm network connectivity** — workers need to reach the Hatchet engine over gRPC. Firewalls, VPNs, or missing TLS configuration can block this. +5. **Check SDK version** — ensure your SDK version is compatible with your engine version. Mismatches can cause subtle failures. + +## Could not send task to worker + +If you see this error in the event history of a task, it could mean several things: + +1. The worker is closing its network connection while the task is being sent. This could be caused by the worker crashing or going offline. + +2. The payload is too large for the worker to accept or the Hatchet engine to send. The default maximum payload size is 4MB. Consider reducing the size of the input data or output data of your tasks. + +3. The worker has a large backlog of tasks in-flight on the network connection and is rejecting new tasks. This can occur if workers are geographically distant from the Hatchet engine or if there are network issues causing delays. Hatchet Cloud runs by default in `us-west-2` (Oregon, USA), so consider deploying your workers in a region close to that for the best performance. + + If you are self-hosting, you can increase the maximum backlog size via the `SERVER_GRPC_WORKER_STREAM_MAX_BACKLOG_SIZE` environment variable in your Hatchet engine configuration. The default is 20. + +## No workers visible in dashboard + +If you have deployed workers but they are not visible in the Hatchet dashboard, it is likely that: + +1. Your API token is invalid or incorrect. Ensure that the token you are using to start the worker matches the token generated in the Hatchet dashboard for your tenant. + +2. Worker heartbeats are not reaching the Hatchet engine. You will see noisy logs in the worker output if this is the case. + +## Tasks stuck in QUEUED state + +If tasks remain in the `QUEUED` state and never move to `RUNNING`: + +1. **No workers registered for the task** — check the Workers tab in the dashboard and confirm a worker is registered that handles the task name. If you recently renamed a task, make sure the worker has been restarted with the updated code. + +2. **All worker slots are full** — if every slot is occupied by other tasks, new tasks will wait in the queue. Check worker utilization in the dashboard or increase the [slot count](/concepts/workers#slots). + +3. **Concurrency or rate limit is blocking** — if you've configured [concurrency limits](/concepts/concurrency) or [rate limits](/concepts/rate-limits), tasks may be held back intentionally. Review your configuration. + +## Worker keeps disconnecting + +If your worker repeatedly connects and then drops: + +1. **Resource exhaustion** — the worker process may be running out of memory or CPU and getting killed by the OS or orchestrator (OOM kill). Check system logs and increase resource limits. + +2. **Network instability** — intermittent connectivity between the worker and the Hatchet engine will cause reconnection cycles. Check for packet loss or high latency between the worker and the engine. + +3. **Graceful shutdown not configured** — if your deployment platform sends `SIGTERM` and the worker doesn't handle it, in-flight tasks may be interrupted. Ensure your worker handles shutdown signals and gives tasks time to complete. + +## Phantom workers active in dashboard + +This is often due to workers still running in your deployed environment. We see this most often with very long termination periods for workers, or in local development environments where worker processes are leaking. If you are in a local development environment, you can usually view running Hatchet worker processes via `ps -a | grep worker` (or whatever your entrypoint binary is called) and kill them manually. diff --git a/frontend/docs/pages/essentials/uptime.mdx b/frontend/docs/pages/essentials/uptime.mdx new file mode 100644 index 0000000000..a8188e10ca --- /dev/null +++ b/frontend/docs/pages/essentials/uptime.mdx @@ -0,0 +1,20 @@ +# Uptime and status + +For Hatchet Cloud availability and incident updates, use the status page. For self-hosted deployments, availability depends on your own infrastructure. + +## Hatchet Cloud status page + +Use **[status.hatchet.run](https://status.hatchet.run/)** for real-time status and incident history for Hatchet Cloud and related services: + +- **API**: Hatchet API availability +- **Hatchet Cloud**: `cloud.onhatchet.run` +- **Website**: `hatchet.run` and documentation sites + +You can also subscribe to updates (email/SMS/etc.) directly from the status page. + +## Self-hosted deployments + +If you self-host Hatchet, you’re responsible for uptime, monitoring, backups, and upgrade procedures. + +- **Deployment guidance**: [Self Hosting](/self-hosting) +- **Redundancy & failover**: [High Availability](/self-hosting/high-availability) diff --git a/frontend/docs/pages/essentials/using-coding-agents.mdx b/frontend/docs/pages/essentials/using-coding-agents.mdx new file mode 100644 index 0000000000..dc9daf7647 --- /dev/null +++ b/frontend/docs/pages/essentials/using-coding-agents.mdx @@ -0,0 +1,151 @@ +import { Callout, Steps, Tabs } from "nextra/components"; +import { + McpUrl, + CursorDeeplinkButton, + CursorMcpConfig, + ClaudeCodeCommand, + CursorTabLabel, + ClaudeCodeTabLabel, + OtherAgentsTabLabel, +} from "@/components/McpSetup"; + +# Using Coding Agents + +Hatchet is designed to work well with AI coding agents. This page covers how to give your agent access to Hatchet documentation and step-by-step skills for common CLI operations. + + + **Prerequisite:** The `hatchet skills install` and `hatchet docs install` + commands require the Hatchet CLI. See the [CLI reference](/cli) for + installation instructions. + + +## Agent Skills + +Agent skills are reference documents that teach AI coding agents how to use the Hatchet CLI — triggering workflows, starting workers, debugging runs, and more. + +Run the following command in your project root to install the skill package: + +```bash copy +hatchet skills install +``` + +This creates a `skills/hatchet-cli/` directory with step-by-step reference files and appends a section to your project's `AGENTS.md` (and `CLAUDE.md`) pointing agents to the right file for each task. + +**Install to a custom directory:** + +```bash copy +hatchet skills install --dir ./my-project +``` + +After installation, commit the `skills/` directory and `AGENTS.md` to version control so all agents working in the repo benefit automatically. + +### Available references + +| Reference | When to use | +| --------------------------------- | ----------------------------------------------------- | +| `references/setup-cli.md` | Installing the CLI, creating or listing profiles | +| `references/start-worker.md` | Starting a dev worker for local development | +| `references/trigger-and-watch.md` | Triggering a workflow and polling for completion | +| `references/debug-run.md` | Diagnosing a failed, stuck, or unexpected run | +| `references/replay-run.md` | Re-running a previous workflow with same or new input | + +## MCP Server + +Hatchet documentation is available as an **MCP (Model Context Protocol) server**, so AI coding assistants like Cursor and Claude Code can search and reference Hatchet docs directly. + +MCP endpoint: + +, , ]}> + + + + + + ```bash copy + hatchet docs install cursor + ``` + + This creates a `.cursor/rules/hatchet-docs.mdc` file and prints the one-click deeplink. + + + + + Install the Hatchet docs MCP server in Cursor with one click: + + + + + + + + + ### Open Cursor Settings + + Go to **Cursor Settings** → **MCP** → **Add new MCP server**. + + ### Add the server configuration + + + + ### Use in chat + + Reference Hatchet docs in any Cursor chat with `@hatchet-docs` or ask questions and the agent will automatically search the docs. + + + + + + + + + + + + + ```bash copy + hatchet docs install claude-code + ``` + + If `claude` is on your PATH, this runs the command automatically. Otherwise it prints it for you to copy. + + + + + Run this command in your terminal: + + + + + + + + + + For any AI tool that supports [llms.txt](https://llmstxt.org/), Hatchet docs are available at: + + | Resource | URL | + |----------|-----| + | **llms.txt** (index) | [docs.hatchet.run/llms.txt](https://docs.hatchet.run/llms.txt) | + | **llms-full.txt** (all docs) | [docs.hatchet.run/llms-full.txt](https://docs.hatchet.run/llms-full.txt) | + | **Per-page markdown** | `docs.hatchet.run/llms/{section}/{page}.md` | + | **MCP endpoint** | | + + Every documentation page also includes a `` header + pointing to its markdown version, and a "View as Markdown" link at the top of the page. + + + + +## llms.txt + +For any AI tool that supports [llms.txt](https://llmstxt.org/), Hatchet docs are available at: + +| Resource | URL | +| ---------------------------- | ------------------------------------------------------------------------ | +| **llms.txt** (index) | [docs.hatchet.run/llms.txt](https://docs.hatchet.run/llms.txt) | +| **llms-full.txt** (all docs) | [docs.hatchet.run/llms-full.txt](https://docs.hatchet.run/llms-full.txt) | +| **Per-page markdown** | `docs.hatchet.run/llms/{section}/{page}.md` | +| **MCP endpoint** | | + +Every documentation page also includes a `` header +pointing to its markdown version, and a "View as Markdown" link at the top of the page. diff --git a/frontend/docs/pages/essentials/workers.mdx b/frontend/docs/pages/essentials/workers.mdx new file mode 100644 index 0000000000..74a9b08efc --- /dev/null +++ b/frontend/docs/pages/essentials/workers.mdx @@ -0,0 +1,115 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Workers + +Workers are long-running processes in your infrastructure that execute tasks. Each worker maintains a persistent gRPC connection to the Hatchet engine, receives task assignments, runs your code, and reports results back. You can run workers locally during development, in containers, or on VMs - and scale them independently from the Hatchet services. + +You register tasks on a worker so Hatchet knows where to send work. When a task is triggered, Hatchet assigns it to a worker that has a free [slot](/concepts/workers#slots). Slots prevent overassignment - each worker only accepts as many tasks as it can handle concurrently. + +## Declaring a Worker + +With a [task declared](./your-first-task.mdx), create a worker to execute it. Call the `worker` method on the Hatchet client with a name and the tasks it should handle. + + + + + + + **Windows users:** Workers require [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) or [Docker](/concepts/docker). Client code (triggering tasks, querying the API) works natively on Windows, but workers must run in a Linux environment. + + + + + + + + + + + There are both `worker.Start` and `worker.StartBlocking` methods. `StartBlocking` blocks the main thread until the worker is stopped, while `Start` returns immediately and requires a later call to `worker.Stop`. + + + + + + + + +## Starting a Worker + + + + +The fastest way to run a worker during development is with the Hatchet CLI. This handles authentication and hot-reloads your worker when code changes: + +```bash +hatchet worker dev +``` + + + + +You can also run the worker script directly. This requires a `HATCHET_CLIENT_TOKEN` to be set - see [Advanced Setup](/essentials/advanced) for how to provision one. + + + +```bash +python worker.py +``` + + + +Add a script to your `package.json`: + +```json +"scripts": { + "start:worker": "ts-node src/worker.ts" +} +``` + +Then run it: + +```bash +npm run start:worker +``` + + + +```bash +go run main.go +``` + + +```bash +bundle exec ruby worker.rb +``` + + + + + + +Once the worker starts, you'll see logs confirming it's connected: + +``` +[INFO] 🪓 -- STARTING HATCHET... +[DEBUG] 🪓 -- 'test-worker' waiting for ['simpletask:step1'] +[DEBUG] 🪓 -- acquired action listener: efc4aaf2-... +[DEBUG] 🪓 -- sending heartbeat +``` + + + For self-hosted users, you may need to set additional gRPC configuration + options. See the + [Self-Hosting](../self-hosting/worker-configuration-options.mdx) docs for + details. + + +## Next steps + +Now that you have a worker running, you can [run your first task](/essentials/running-your-task). + +Want to dive deeper? The [Workers concepts page](/concepts/workers) covers the worker lifecycle, slots, scaling strategies, and task assignment in detail. diff --git a/frontend/docs/pages/essentials/your-first-task.mdx b/frontend/docs/pages/essentials/your-first-task.mdx new file mode 100644 index 0000000000..507be87626 --- /dev/null +++ b/frontend/docs/pages/essentials/your-first-task.mdx @@ -0,0 +1,64 @@ +import { snippets } from "@/lib/generated/snippets"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Declaring Your First Task + +Everything you run in Hatchet is a **task** - a named function that you can trigger, retry, schedule, and observe. Tasks can be configured to handle the problems that come up in real systems. + +## Defining a Task + +At minimum, a task needs a name and a function. The returned object is a **runnable** - you'll use it directly to [trigger](/essentials/running-your-task) the task. + + + + + + + + + + + + + + + + + +## Task lifecycle + +When you trigger a task, it moves through three phases: queued, running, and a terminal state. + +```mermaid +graph LR + Triggered --> QUEUED + QUEUED -->|Assigned to worker| RUNNING + RUNNING -->|Success| COMPLETED + RUNNING -->|Failure + retries left| QUEUED + RUNNING -->|Failure + no retries| FAILED +``` + +A task can also be **CANCELLED** at any point - either explicitly or by a [timeout](/concepts/timeouts) expiring. + +## Configuring a task + +Tasks can be configured to handle common problems in distributed systems. For example, you might want to automatically retry a task when an external API returns a transient error, or limit how many instances of a task run at the same time to avoid overwhelming a downstream service. + +| Concept | What it does | +| -------------------------------------------- | ---------------------------------------------------------- | +| [Retries](/concepts/retry-policies) | Retry the task on failure, with optional backoff. | +| [Timeouts](/concepts/timeouts) | Limit how long a task may wait to be scheduled or to run. | +| [Concurrency](/concepts/concurrency) | Limit how many runs of this task execute at once. | +| [Rate limits](/concepts/rate-limits) | Throttle task execution over a time window. | +| [Priority](/concepts/priority) | Influence scheduling order relative to other queued tasks. | +| [Worker affinity](/concepts/worker-affinity) | Prefer or require specific workers for this task. | + +See the [Concepts](/concepts) page for a complete overview of all possible configurations. + +## Next steps + +Now that you have a task defined, [create a worker](/essentials/workers) to execute it. + +Want to dive deeper? The [Tasks concepts page](/concepts/tasks) covers triggering methods, input/output, the context object, and how tasks execute on workers. diff --git a/frontend/docs/pages/home/_meta.js b/frontend/docs/pages/home/_meta.js deleted file mode 100644 index 402e3abe23..0000000000 --- a/frontend/docs/pages/home/_meta.js +++ /dev/null @@ -1,154 +0,0 @@ -export default { - "--intro": { - title: "Why Hatchet?", - type: "separator", - }, - index: "🪓 Welcome", - architecture: "Architecture", - "guarantees-and-tradeoffs": "Guarantees & Tradeoffs", - "--workflows": { - title: "Workflows", - type: "separator", - }, - "workflows-overview": "Workflows (DAGs)", - - "durable-workflows-overview": "Durable Workflows", - "--patterns-and-use-cases": { - title: "Patterns and Use Cases", - type: "separator", - }, - patterns: "Patterns", - "use-cases": "Use Cases", - "--quickstart": { - title: "Setup", - type: "separator", - }, - "hatchet-cloud-quickstart": "Hatchet Cloud Quickstart", - setup: "Advanced Setup", - "install-docs-mcp": "Install Docs MCP", - "--guide": { - title: "Fundamentals", - type: "separator", - }, - "your-first-task": "Tasks", - workers: "Workers", - "running-your-task": "Running Tasks", - environments: "Environments", - "--running-tasks": { - title: "Ways of Running Tasks", - type: "separator", - }, - "running-tasks": "Running Tasks", - "run-with-results": "Run and Wait Trigger", - "run-no-wait": "Run Without Wait Trigger", - "scheduled-runs": "Scheduled Trigger", - "cron-runs": "Cron Trigger", - "run-on-event": "Event Trigger", - "bulk-run": "Bulk Run Many", - webhooks: "Webhooks", - "inter-service-triggering": "Inter-Service Triggering", - "--deploying-workers": { - title: "Deploying Workers", - type: "separator", - }, - docker: "Running with Docker", - "troubleshooting-workers": "Troubleshooting", - compute: { - title: "Managed Compute", - type: "page", - display: "hidden", - }, - "worker-healthchecks": "Worker Health Checks", - "autoscaling-workers": "Autoscaling Workers", - "--flow-control": { - title: "Flow Control", - type: "separator", - }, - concurrency: "Concurrency", - "rate-limits": "Rate Limits", - priority: "Priority", - "--advanced-workflows": { - title: "Workflows", - type: "separator", - }, - orchestration: "Task Orchestration", - dags: { - title: "Directed Acyclic Graphs (DAGs)", - }, - "conditional-workflows": "Conditional Workflows", - "on-failure-tasks": "On Failure Tasks", - "child-spawning": { - title: "Child Spawning", - }, - "additional-metadata": { - title: "Additional Metadata", - }, - "--durable-execution": { - title: "Durable Execution", - type: "separator", - }, - "durable-execution": { - title: "Durable Execution", - }, - "durable-events": { - title: "Durable Events", - }, - "durable-sleep": { - title: "Durable Sleep", - }, - "durable-best-practices": { - title: "Best Practices", - }, - "--error-handling": { - title: "Error Handling", - type: "separator", - }, - timeouts: "Timeouts", - "retry-policies": "Retry Policies", - "bulk-retries-and-cancellations": "Bulk Retries and Cancellations", - - "--assignment": { - title: "Advanced Assignment", - type: "separator", - }, - "sticky-assignment": "Sticky Assignment", - "worker-affinity": "Worker Affinity", - "manual-slot-release": "Manual Slot Release", - "--observability": { - title: "Observability", - type: "separator", - }, - logging: "Logging", - opentelemetry: "OpenTelemetry", - "prometheus-metrics": "Prometheus Metrics", - "--advanced-tasks": { - title: "Advanced Task Features", - type: "separator", - }, - cancellation: { - title: "Cancellation", - }, - streaming: { - title: "Streaming", - }, - "--v1-migration-guides": { - title: "V1 Migration Guides", - type: "separator", - }, - "v1-sdk-improvements": { - title: "SDK Improvements", - }, - "migration-guide-engine": "Engine Migration Guide", - "migration-guide-python": "Python Migration Guide", - "migration-guide-typescript": "Typescript Migration Guide", - "migration-guide-go": "Go Migration Guide", - "--python": { - title: "Python Specifics", - type: "separator", - }, - asyncio: "Asyncio", - pydantic: "Pydantic", - lifespans: "Lifespans", - "dependency-injection": "Dependency Injection", - dataclasses: "Dataclass Support", -}; diff --git a/frontend/docs/pages/home/architecture.mdx b/frontend/docs/pages/home/architecture.mdx deleted file mode 100644 index 4014b9024a..0000000000 --- a/frontend/docs/pages/home/architecture.mdx +++ /dev/null @@ -1,99 +0,0 @@ -# Architecture - -## Overview - -Hatchet's architecture is designed around simplicity and reliability. At its core, Hatchet consists of three main components: the **Engine**, the **API Server**, and **Workers**. State is managed durably and efficiently, eliminating the need for additional message brokers or distributed systems. - -Whether you use [Hatchet Cloud](https://cloud.onhatchet.run) or self-host, the architecture remains consistent, allowing seamless migration between deployment models as your needs evolve. - -```mermaid -graph LR - subgraph "External (Optional)" - EXT[Webhooks
Events] - end - - subgraph "Your Infrastructure" - APP[Your API, App, Service, etc.] - W[Workers] - end - - subgraph "Hatchet" - API[API Server] - ENG[Engine] - DB[(Database)] - end - - EXT --> API - APP <--> API - API --> ENG - ENG <--> DB - API <--> DB - ENG <-.->|gRPC| W - - classDef userInfra fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1 - classDef hatchet fill:#f1f8e9,stroke:#388e3c,stroke-width:2px,color:#1b5e20 - classDef external fill:#fff8e1,stroke:#f57c00,stroke-width:2px,color:#e65100 - - class APP,W userInfra - class API,ENG,DB hatchet - class EXT external -``` - -## Core Components - -### Engine - -The **Hatchet Engine** orchestrates the entire workflow execution process. It determines when and where tasks should run based on complex dependencies, concurrency limits, and worker availability. Key responsibilities include: - -- **Task Scheduling**: Intelligent routing based on worker capacity and constraints -- **Queue Management**: Sophisticated priority, rate limiting, and fairness algorithms -- **Flow Control**: Enforces concurrency limits, rate limits, and routing rules -- **Retry Logic**: Automatic handling of failures, timeouts, and backpressure -- **Cron Processing**: Manages scheduled workflow executions - -Communication with workers are handled through bidirectional gRPC connections that enable real-time task dispatch and status updates with minimal latency and network overhead. The Hatchet engine continuously tracks task execution state and automatically handles retries, timeouts, and failure scenarios without manual intervention. - -The Engine is designed to be horizontally scalable—multiple engine instances can run simultaneously, coordinating through the persistent storage layer to handle increasing workloads seamlessly. - -### API Server - -The **API Server** serves as the primary interface for viewing Hatchet resources. It exposes REST endpoints that the Hatchet UI and your applications use to: - -- **Trigger Workflows**: Start new workflow executions with input data -- **Query or Subscribe to Status**: Check workflow and task execution status -- **Manage Resources**: Configure workflows, schedules, and settings -- **Webhook Ingestion**: Receive and process external events - -Security is handled through multi-tenant authentication with API keys and JWT tokens, or webhook signature verification where applicable, to ensure only authentic requests are processed. The API Server also powers Hatchet's web dashboard through REST endpoints, giving you real-time visibility into your workflows. - -### Workers - -**Workers** are your application processes that execute the actual business logic. They establish secure, bidirectional gRPC connections to the Engine and run your functions when tasks are dispatched. Workers continuously report status updates, including task progress, logs, and results, giving you real-time visibility into execution. - -When tasks need to be cancelled, workers handle this gracefully with proper cleanup procedures. One of Hatchet's key design goals is deployment flexibility: workers can run anywhere, from containers to VMs or even your local development machine. This flexibility means you can start development locally, deploy to staging in containers, and run production workloads on dedicated infrastructure without changing your worker code. - -You can run either homogeneous or heterogeneous workers. Homogeneous workers are a single type of worker that is used for all tasks. Heterogeneous workers are a mix of different types of workers that are used for different tasks. - -Heterogeneous workers can also be polyglot, meaning they can run multiple languages. For example, you can run a Python worker, a Go worker, and a TypeScript worker which can all be invoked from the same service application. - -### Persistent Storage & Inter-Service Communication - -The platform maintains durable state for all aspects of workflow execution, including task queue state for queued, running, and completed tasks. Workflow definitions with their dependencies, configuration, and metadata are stored persistently, ensuring your orchestration logic survives system restarts. - -In [self-hosted deployments](../self-hosting), this can be a single PostgreSQL database, or for high-throughput workloads you can use RabbitMQ for inter-service communication. In [Hatchet Cloud](https://hatchet.run), this is managed for you with enterprise-grade reliability and performance, handling backups, scaling, and maintenance automatically. - -## Design Philosophy - -Hatchet prioritizes simplicity over complexity: - -- **PostgreSQL foundation** - Built on PostgreSQL with optional RabbitMQ for high-throughput workloads -- **Stateless services** - Engine and API scale horizontally -- **Worker flexibility** - Deploy anywhere, any language (Python/TypeScript/Go), independent scaling - -## Next Steps - -**[Guarantees & Tradeoffs](./guarantees-and-tradeoffs.mdx)** - Learn about Hatchet's guarantees, limitations, and performance characteristics. - -**[Quick Start](./setup.mdx)** - Set up your first Hatchet worker. - -**[Self Hosting](../self-hosting)** - Deploy the Hatchet platform on your own infrastructure. diff --git a/frontend/docs/pages/home/conditional-workflows.mdx b/frontend/docs/pages/home/conditional-workflows.mdx deleted file mode 100644 index 0b9cfe7987..0000000000 --- a/frontend/docs/pages/home/conditional-workflows.mdx +++ /dev/null @@ -1,225 +0,0 @@ -import { Snippet } from "@/components/code"; -import { snippets } from "@/lib/generated/snippets"; - -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -## Introduction - -Hatchet V1 introduces the ability to add conditions to tasks in your workflows that determine whether or not a task should be run, based on a number of conditions. Conditions unlock a number of new ways to solve problems with Hatchet, such as: - -1. A workflow that reads a feature flag, and then decides how to progress based on its value. In this case, you'd have two tasks that use parent conditions, where one task runs if the flag value is e.g. `True`, while the other runs if it's `False`. -2. Any type of human-in-the-loop workflow, where you want to wait for a human to e.g. approve something before continuing the dag. - -## Types of Conditions - -There are three types of `Condition`s in Hatchet V1: - -1. Sleep conditions, which sleep for a specified duration before continuing -2. Event conditions, which wait for an event (and optionally a CEL expression evaluated on the payload of that event) before deciding how to continue -3. Parent conditions, which wait for a parent task to complete and then decide how to progress based on its output. - -## Or Groups - -Conditions can also be combined using an `Or` operator into groups of conditions (called "or groups") where at least one must be satisfied in order for the group to evaluate to `True`. An "or group" behaves like a boolean `OR` operator, where the group evaluates to `True` if at least one of its conditions is `True`. - -Or groups are an extremely powerful feature because they let you express arbitrarily complex sets of conditions in [conjunctive normal form](https://en.wikipedia.org/wiki/Conjunctive_normal_form) (CNF) for determining when your tasks should run and when they should not. As a simple example, consider the following conditions: - -- **Condition A**: Checking if the output of a parent task is greater than 50 -- **Condition B**: Sleeping for 30 seconds -- **Condition C**: Receiving the `payment:processed` event - -You might want to progress in your workflow if A _or_ B and C. In this case, we can express this set of conditions in CNF as `A or B` AND `A or C` where both `A or B` and `A or C` are or groups. - -## Usage - -Conditions can be used at task _declaration_ time in three ways: - -1. They can be used in a `wait_for` fashion, where a task will wait for the conditions to evaluate to `True` before being run. -2. They can be used in a `skip_if` fashion, where a task will be skipped if the conditions evaluate to `True`. -3. They can be used in a `cancel_if` fashion, where a task will be cancelled if the conditions evaluate to `True`. - -### `wait_for` - -Declaring a task with conditions to `wait_for` will cause the task to wait before starting for until its conditions evaluate to `True`. For instance, if you use `wait_for` with a 60 second sleep, the workflow will wait for 60 seconds before triggering the task. Similar, if the task is waiting for an event, it will wait until the event is fired before continuing. - -### `skip_if` - -Declaring a task with conditions to `skip_if` will cause the task to be skipped if the conditions evaluate to `True`. For instance, if you use a parent condition to check if the output of a parent task is equal to some value, the task will be skipped if that condition evaluates to `True`. - -### `cancel_if` - -Declaring a task with conditions to `cancel_if` will cause the task to be cancelled if the conditions evaluate to `True`. For instance, if you use a parent condition to check if the output of a parent task is equal to some value, the task will be cancelled if that condition evaluates to `True`. - - - A task cancelled by a `cancel_if` operator will behave the same as any other - cancellation in Hatchet, meaning that downstream tasks will be cancelled as - well. - - -## Example Workflow - -In this example, we're going to build the following workflow: - -![Branching DAG Workflow](/branching-dag.png) - -Note the branching logic (`left_branch` and `right_branch`), as well as the use of skips and waits. - -To get started, let's declare the workflow. - - - - - - - - - - - - - - - - -Next, we'll start adding tasks to our workflow. First, we'll add a basic task that outputs a random number: - - - - - - - - - - - - - - - - -Next, we'll add a task to the workflow that's a child of the first task, but it has a `wait_for` condition that sleeps for 10 seconds. - - - - - - - - - - - - - - - - -This task will first wait for the parent task to complete, and then it'll sleep for 10 seconds before executing and returning another random number. - -Next, we'll add a task that will be skipped on an event: - - - - - - - - - - - - - - - - -In this case, our task will wait for a 30 second sleep, and then it will be skipped if the `skip_on_event:skip` is fired. - -Next, let's add some branching logic. Here we'll add two more tasks, a left and right branch. - - - - - - - - - - - - - - - - -These two tasks use the `ParentCondition` and `skip_if` together to check if the output of an upstream task was greater or less than `50`, respectively. Only one of the two tasks will run: whichever one's condition evaluates to `True`. - -Next, we'll add a task that waits for an event: - - - - - - - - - - - - - - - - -And finally, we'll add the last task, which collects all of its parents and sums them up. - - - - - -Note that in this task, we rely on `ctx.was_skipped` to determine if a task was skipped. - - - - - - - - - - - - - - -This workflow demonstrates the power of the new conditional logic in Hatchet V1. You can now create complex workflows that are much more dynamic than workflows in the previous version of Hatchet, and do all of it declaratively (rather than, for example, by dynamically spawning child workflows based on conditions in the parent). diff --git a/frontend/docs/pages/home/durable-events.mdx b/frontend/docs/pages/home/durable-events.mdx deleted file mode 100644 index 6c49b830ec..0000000000 --- a/frontend/docs/pages/home/durable-events.mdx +++ /dev/null @@ -1,60 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -## Durable Events - -Durable events are a feature of **durable tasks** which allow tasks to wait for an event to occur before continuing. This is useful in cases where a task needs to wait for a long time for an external action. Durable events are useful, because even if your task is interrupted and requeued while waiting for an event, the event will still be processed. When the task is resumed, it will read the event from the durable event log and continue processing. - -## Declaring durable events - -Durable events are declared using the context method `WaitFor` (or utility method `WaitForEvent`) on the `DurableContext` object. - - - - - - - - - - - - - - - - - - - - - - -## Durable event filters - -Durable events can be filtered using [CEL](https://github.com/google/cel-spec) expressions. For example, to only receive `user:update` events for a specific user, you can use the following filter: - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/docs/pages/home/durable-execution.mdx b/frontend/docs/pages/home/durable-execution.mdx deleted file mode 100644 index 7c1985935c..0000000000 --- a/frontend/docs/pages/home/durable-execution.mdx +++ /dev/null @@ -1,60 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; - -import { Callout } from "nextra/components"; - -# Durable Execution - -## Introduction - -**Durable execution** refers to the ability of a function to easily recover from failures or interruptions. In Hatchet, we refer to a function with this ability as a **durable task**. Durable tasks are essentially tasks that store intermediate results in a durable event log - in other words, they're a fancy cache. - - - For an in-depth look at how durable execution works, have a look at [this blog - post](https://hatchet.run/blog/durable-execution). - - -This is especially useful in cases such as: - -1. Tasks which need to always run to completion, even if the underlying machine crashes or the task is interrupted. -2. Situations where a task needs to wait for an very long amount of time for something to complete before continuing. Running a durable task will not take up a slot on the main worker, so is a strong candidate for e.g. fanout tasks that spawn a large number of children and then wait for their results. -3. Waiting for a potentially long time for an event, such as human-in-the-loop tasks where we might not get human feedback for hours or days. - -## How Hatchet Runs Durable Tasks - -Durable tasks run on the same worker process as regular tasks, but they consume a separate slot type so they do not compete with regular tasks for slots. This pattern prevents deadlock scenarios where durable tasks would starve children tasks for slots which are needed for the parent durable task to complete. - -Tasks that are declared as being durable (using `durable_task` instead of `task`), will receive a `DurableContext` object instead of a normal `Context,` which extends the `Context` by providing some additional tools for working with durable execution features. - -## Example Task - -Now that we know a bit about how Hatchet handles durable execution, let's build a task. We'll start by declaring a task that will run durably. - - - -Here, we've declared a Hatchet task just like any other. Now, we can add some tasks to it: - - - -We've added two tasks to our workflow. The first is a normal, "ephemeral" task, which does not leverage any of Hatchet's durable features. - -Second, we've added a durable task, which we've created by using the `durable_task` method of the `Workflow`, as opposed to the `task` method. - - - Note that the `durable_task` we've defined takes a `DurableContext`, as - opposed to a regular `Context`, as its second argument. The `DurableContext` - is a subclass of the regular `Context` that adds some additional methods for - working with durable tasks. - - -The durable task first waits for a sleep condition. Once the sleep has completed, it continues processing until it hits the second `wait_for`. At this point, it needs to wait for an event condition. Once it receives the event, the task prints `Event received` and completes. - -If this task is interrupted at any time, it will continue from where it left off. But more importantly, if an event comes into the system while the task is waiting, the task will immediately process the event. And if the task is interrupted while in a sleeping state, it will respect the original sleep duration on restart -- that is, if the task calls `ctx.aio_sleep_for` for 24 hours and is interrupted after 23 hours, it will only sleep for 1 more hour on restart. - -### Or Groups - -Similarly to in [conditional workflows](./conditional-workflows.mdx#or-groups), durable tasks can also use or groups in the wait conditions they use. For example, you could wait for either an event or a sleep (whichever comes first) like this: - - diff --git a/frontend/docs/pages/home/durable-sleep.mdx b/frontend/docs/pages/home/durable-sleep.mdx deleted file mode 100644 index 70abcf789e..0000000000 --- a/frontend/docs/pages/home/durable-sleep.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -## Durable Sleep - -Durable sleep is a feature of **durable tasks** which allow tasks to pause execution for a specified amount of time. Instead of a regular `sleep` call in your task, durable sleep is guaranteed to only sleep for the specified amount of time after the first time it was called. - -For example, say you'd like to send a notification to a user after 24 hours. With a regular `sleep`, if the task is interrupted after 23 hours, it will restart and call `sleep` for 24 hours again. This means that the task will sleep for 47 hours in total, which is not what you want. With durable sleep, the task will respect the original sleep duration on restart -- that is, if the task calls `ctx.aio_sleep_for` for 24 hours and is interrupted after 23 hours, it will only sleep for 1 more hour on restart. - -## Using durable sleep - -Durable sleep can be used by calling the `SleepFor` method on the `DurableContext` object. This method takes a duration as an argument and will sleep for that duration. - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/docs/pages/home/durable-workflows-overview.mdx b/frontend/docs/pages/home/durable-workflows-overview.mdx deleted file mode 100644 index bbd6e8b830..0000000000 --- a/frontend/docs/pages/home/durable-workflows-overview.mdx +++ /dev/null @@ -1,87 +0,0 @@ -import { Callout, Cards, Steps } from "nextra/components"; -import DurableWorkflowDiagram from "@/components/DurableWorkflowDiagramWrapper"; - -# Durable Workflows - -A **durable workflow** is a long-running task that stores its progress in a durable event log. If the task is interrupted (such as a crash, a deployment, or a scaling event), it replays from its last checkpoint instead of starting over. This makes durable workflows ideal for tasks that require deterministic behavior and outcomes. - - - -## How Durable Execution Works - - - -### Task runs and checkpoints - -As a durable task executes, each call to `SleepFor`, `WaitForEvent`, `WaitFor`, or `Memo` creates a checkpoint in the durable event log. These checkpoints record the task's progress. - -### Worker slot is freed during waits - -When a durable task enters a long wait (or sleep), the worker slot is released. The task is not consuming compute resources while waiting, unlike a regular task that holds its slot for the entire duration. - -### Task resumes from checkpoint - -If the task is interrupted or the wait completes, Hatchet replays the event log up to the last checkpoint and resumes execution from there. Completed operations are not re-executed. - - - -## The Durable Context - -Durable tasks receive a `DurableContext` instead of a regular `Context`. This extends the standard context with methods for durable execution: - -| Method | Purpose | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`SleepFor(duration)`** | Pause for a fixed duration. Respects the original sleep time on restart; if interrupted after 23 of 24 hours, only sleeps 1 more hour | -| **`WaitForEvent(key, expr)`** | Wait for an external event by key, with optional [CEL filter](https://github.com/google/cel-spec) expression on the payload | -| **`WaitFor(conditions)`** | General-purpose wait accepting any combination of sleep conditions, event conditions, or or-groups. `SleepFor` and `WaitForEvent` are convenience wrappers around this method | -| **`Memo(function)`** | Run functions whose outputs are memoized based on the input arguments | - -## Determinism Rules - -Durable tasks **must be deterministic**: they must perform the same sequence of operations on every replay. This is what allows Hatchet to safely skip already-completed work. - - - -### WIP - -### Never change the order of operations - -If your task calls `SleepFor` then `WaitForEvent`, don't later swap their order. Existing checkpoints depend on the original sequence. Changing it breaks replay. - -### WIP - - - - - If your workflow can be expressed as a [DAG](/home/dags), prefer regular - workflows. DAGs are inherently deterministic and don't require you to think - about replay safety. Use durable workflows when you need inline `SleepFor`, - `WaitForEvent`, or logic that can't be expressed as a static graph. - - -## When to Use Durable Workflows - -| Scenario | Why Durable? | -| --------------------------------- | ---------------------------------------------------------------------- | -| **Long waits** (hours/days) | Worker slots are freed during waits; no wasted compute | -| **Human-in-the-loop** | Wait for approval events without holding resources | -| **Multi-step with inline pauses** | `SleepFor` and `WaitForEvent` let you express complex procedural flows | -| **Large fan-out with collection** | Spawn many children and wait for results without holding a slot | -| **Crash-resilient pipelines** | Automatically resume from checkpoints after failures | - -## Next Steps - - - - Pause tasks for exact durations with crash-safe timing guarantees. - - - Wait for external signals with key matching and CEL filter expressions. - - - Compare with regular DAG workflows and understand when to use each. - - - See the long waits pattern in action with interactive diagrams. - - diff --git a/frontend/docs/pages/home/guarantees-and-tradeoffs.mdx b/frontend/docs/pages/home/guarantees-and-tradeoffs.mdx deleted file mode 100644 index d055fadf10..0000000000 --- a/frontend/docs/pages/home/guarantees-and-tradeoffs.mdx +++ /dev/null @@ -1,135 +0,0 @@ -import { Callout } from "nextra/components"; - -# Guarantees & Tradeoffs - -Hatchet is designed as a modern task orchestration platform that bridges the gap between simple job queues and complex workflow engines. Understanding where it excels—and where it doesn't—will help you determine if it's the right fit for your needs. - -### Good Fit - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Real-time Requests - Sub-25ms task dispatch for hot - workers with thousands of concurrent tasks -
- Workflow Orchestration with dependencies and error - handling -
- Reliable Task Processing where durability matters -
- Moderate Throughput (hundreds to low 10,000s of - tasks/second) -
- Multi-Language Workers or polyglot teams -
- Operational Simplicity if your team is already using - PostgreSQL -
- Cloud or Air-Gapped Environments for flexible - deployment options ( - Hatchet Cloud and{" "} - self-hosting) -
- -### Not a Good Fit - - - - - - - - - - - - - - - - - - - - -
- Extremely High Throughput (consistently 10,000+ - tasks/second) -
- Sub-Millisecond Latency requirements -
- Memory-Only Queuing where persistence or durability - isn't needed -
- Serverless Environments on cloud providers like AWS - Lambda, Google Cloud Functions, or Azure Functions -
- -## Core Reliability Guarantees - -Hatchet is designed with the following core reliability guarantees: - -**Every task will execute at least once.** Hatchet ensures that no task gets lost, even during system failures, network outages, or deployments. Failed tasks automatically retry according to your configuration, and all tasks persist through restarts and network issues. - -**Consistent state management.** All workflow state changes happen within PostgreSQL transactions, ensuring that your workflow dependencies resolve consistently and no tasks are lost during failures or deployments. - -**Predictable execution order.** The default task assignment strategy is First In First Out (FIFO) which can be modified with [concurrency policies](./concurrency.mdx), [rate limits](./rate-limits.mdx), and [priorities](./priority.mdx). - -**Operational resilience.** The engine and API servers are stateless, allowing them to restart without losing state and enabling horizontal scaling by simply adding more instances. Workers automatically reconnect after network issues and can be deployed anywhere—containers, VMs, or local development environments. - -## Performance Expectations - -Understanding Hatchet's performance characteristics helps you plan your implementation and set realistic expectations. - -**Typical time-to-start latency** for task dispatch is sub 50ms with PostgreSQL storage, though this can be optimized to ~25ms P95 for hot workers in optimized setups. Network latency between your workers and the Hatchet engine will directly impact dispatch times, so consider deployment topology when latency matters. - -**Throughput capacity** varies significantly based on your setup. A single engine instance with PostgreSQL-only storage typically handles hundreds of tasks per second. When you need higher throughput, adding RabbitMQ as a message queue can substantially increase capacity, though your database will eventually become the bottleneck at very high scales. Through tuning and sharding, we can support throughputs of tens of thousands of tasks per second. - -**Concurrent processing** scales well — Hatchet supports thousands of concurrent workers, with worker-level concurrency controlled through slot configuration. The depth of your queues is limited by your database storage capacity rather than memory constraints. - -**Performance optimization** comes through several strategies: RabbitMQ for high-throughput workloads, read replicas for analytics queries, connection pooling with tools like PgBouncer, and shorter retention periods for execution history. Conversely, performance can be limited by database connection limits, large task payloads (over 1MB), complex dependency graphs, and cross-region network latency. - - - -**Not seeing expected performance?** - -If you're not seeing the performance you expect, please [reach out to us](https://hatchet.run/office-hours) or [join our community](https://hatchet.run/discord) to explore tuning options. - - - -## Ready to Get Started? - -Now that you understand Hatchet's capabilities and limitations, explore the technical details: - -**[Quick Start](../setup.mdx)** - Set up your first Hatchet worker. - -**[Self-Hosting](../self-hosting)** - Learn how to deploy Hatchet on your own infrastructure with appropriate sizing for your needs. diff --git a/frontend/docs/pages/home/install-docs-mcp.mdx b/frontend/docs/pages/home/install-docs-mcp.mdx deleted file mode 100644 index 68c6e7b5f1..0000000000 --- a/frontend/docs/pages/home/install-docs-mcp.mdx +++ /dev/null @@ -1,97 +0,0 @@ -import { Callout, Steps, Tabs } from "nextra/components"; -import { - McpUrl, - CursorDeeplinkButton, - CursorMcpConfig, - ClaudeCodeCommand, - CursorTabLabel, - ClaudeCodeTabLabel, - OtherAgentsTabLabel, -} from "@/components/McpSetup"; - -# Install Docs MCP - -Hatchet documentation is optimized for LLMs and available as an **MCP (Model Context Protocol) server**, so AI coding assistants like Cursor and Claude Code can search and reference Hatchet docs directly. - -MCP endpoint: - -, , ]}> - - - - - - ```bash copy - hatchet docs install cursor - ``` - - This creates a `.cursor/rules/hatchet-docs.mdc` file and prints the one-click deeplink. - - - - - Install the Hatchet docs MCP server in Cursor with one click: - - - - - - - - - ### Open Cursor Settings - - Go to **Cursor Settings** → **MCP** → **Add new MCP server**. - - ### Add the server configuration - - - - ### Use in chat - - Reference Hatchet docs in any Cursor chat with `@hatchet-docs` or ask questions and the agent will automatically search the docs. - - - - - - - - - - - - - ```bash copy - hatchet docs install claude-code - ``` - - If `claude` is on your PATH, this runs the command automatically. Otherwise it prints it for you to copy. - - - - - Run this command in your terminal: - - - - - - - - - - For any AI tool that supports [llms.txt](https://llmstxt.org/), Hatchet docs are available at: - - | Resource | URL | - |----------|-----| - | **llms.txt** (index) | [docs.hatchet.run/llms.txt](https://docs.hatchet.run/llms.txt) | - | **llms-full.txt** (all docs) | [docs.hatchet.run/llms-full.txt](https://docs.hatchet.run/llms-full.txt) | - | **Per-page markdown** | `docs.hatchet.run/llms/{section}/{page}.md` | - | **MCP endpoint** | | - - Every documentation page also includes a `` header - pointing to its markdown version, and a "View as Markdown" link at the top of the page. - - - diff --git a/frontend/docs/pages/home/orchestration.mdx b/frontend/docs/pages/home/orchestration.mdx deleted file mode 100644 index 85ecda61b1..0000000000 --- a/frontend/docs/pages/home/orchestration.mdx +++ /dev/null @@ -1,14 +0,0 @@ -# Task Orchestration - -Not only can you run a single task in Hatchet, but you can also orchestrate multiple tasks together based on a shape that you define. For example, you can run a task that depends on the output of another task, or you can run a task that waits for a certain condition to be met before running. - -1. [Declarative Workflow Design (DAGs)](./dags.mdx) -- which is a way to declaratively define the sequence and dependencies of tasks in a workflow when you know the dependencies ahead of time. -2. [Procedural Child Spawning](./child-spawning.mdx) -- which is a way to orchestrate tasks in a workflow when you don't know the dependencies ahead of time or when the dependencies are dynamic. - -## Flow Controls - -In addition to coordinating the execution of tasks, Hatchet also provides a set of flow control primitives that allow you to orchestrate tasks in a workflow. This allows you to run only what your service can handle at any given time. - -1. [Worker Slots](./workers.mdx#understanding-slots) -- which is a way to control the number of tasks that can be executed concurrently on a given compute process. -2. [Concurrency Control](./concurrency.mdx) -- which is a global way to control the concurrent execution of tasks based on a specific key. -3. [Rate Limiting](./rate-limits.mdx) -- which is a global way to control the rate of task execution based on time period. diff --git a/frontend/docs/pages/home/patterns/_meta.js b/frontend/docs/pages/home/patterns/_meta.js deleted file mode 100644 index 538d8f1302..0000000000 --- a/frontend/docs/pages/home/patterns/_meta.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - fanout: "Fanout", - "pre-determined-pipelines": "Pre-Determined Pipelines", - branching: "Branching", - cycles: "Cycles", - "long-waits": "Long Waits", -}; diff --git a/frontend/docs/pages/home/running-tasks.mdx b/frontend/docs/pages/home/running-tasks.mdx deleted file mode 100644 index cba352bdf0..0000000000 --- a/frontend/docs/pages/home/running-tasks.mdx +++ /dev/null @@ -1,18 +0,0 @@ -import { Callout, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; -import { Snippet } from "@/components/code"; -import { snippets } from "@/lib/generated/snippets"; - -# Running Tasks - -Once you have a running worker, you'll want to run your tasks. Hatchet provides a number of ways of triggering task runs, from which you should select the one(s) that best suit(s) your use case. - -1. Tasks can be [run, and have their results waited on](./run-with-results.mdx) -2. Tasks can be [enqueued without waiting for their results ("fire and forget")](./run-no-wait.mdx). -3. Tasks can be run on [cron schedules](./cron-runs.mdx). -4. Tasks can be [triggered by events](./run-on-event.mdx). -5. Tasks can be [scheduled for a later time](./scheduled-runs.mdx). - -Each of these methods for triggering tasks have their own uses in different scenarios, and the next few sections will give some examples of each. - -These methods can be invoked directly from the workflow definition, or from other services. diff --git a/frontend/docs/pages/home/running-your-task.mdx b/frontend/docs/pages/home/running-your-task.mdx deleted file mode 100644 index b89d6cc305..0000000000 --- a/frontend/docs/pages/home/running-your-task.mdx +++ /dev/null @@ -1,32 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -# Running Your First Task - -With your task defined, you can import it wherever you need to use it and invoke it with the `run` method. - - - - - - - - - - - - - - - - - - -There are many ways to run a task, including: - -- [Running a task with results](./run-with-results.mdx) -- [Enqueuing a task](./run-no-wait.mdx) -- [Scheduling a task](./scheduled-runs.mdx) -- [Scheduling a task with a cron schedule](./cron-runs.mdx) diff --git a/frontend/docs/pages/home/troubleshooting-workers.mdx b/frontend/docs/pages/home/troubleshooting-workers.mdx deleted file mode 100644 index b46cea79b7..0000000000 --- a/frontend/docs/pages/home/troubleshooting-workers.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { Tabs, Callout } from "nextra/components"; - -# Troubleshooting Hatchet Workers - -This guide aims to document common issues when deploying Hatchet workers. - -## Could not send task to worker - -If you see this error in the event history of a task, it could mean several things: - -1. The worker is closing its network connection while the task is being sent. This could be caused by the worker crashing or going offline. - -2. The payload is too large for the worker to accept or the Hatchet engine to send. The default maximum payload size is 4MB. Consider reducing the size of the input data or output data of your tasks. - -3. The worker has a large backlog of tasks in-flight on the network connection and is rejecting new tasks. This can occur if workers are geographically distant from the Hatchet engine or if there are network issues causing delays. Hatchet Cloud runs by default in `us-west-2` (Oregon, USA), so consider deploying your workers in a region close to that for the best performance. - - If you are self-hosting, you can increase the maximum backlog size via the `SERVER_GRPC_WORKER_STREAM_MAX_BACKLOG_SIZE` environment variable in your Hatchet engine configuration. The default is 20. - -## No workers visible in dashboard - -If you have deployed workers but they are not visible in the Hatchet dashboard, it is likely that: - -1. Your API token is invalid or incorrect. Ensure that the token you are using to start the worker matches the token generated in the Hatchet dashboard for your tenant. - -2. Worker heartbeats are not reaching the Hatchet engine. You will see noisy logs in the worker output if this is the case. - -## Phantom workers active in dashboard - -This is often due to workers still running in your deployed environment. We see this most often with very long termination periods for workers, or in local development environments where worker processes are leaking. If you are in a local development environment, you can usually view running Hatchet worker processes via `ps -a | grep worker` (or whatever your entrypoint binary is called) and kill them manually. diff --git a/frontend/docs/pages/home/use-cases/_meta.js b/frontend/docs/pages/home/use-cases/_meta.js deleted file mode 100644 index 510a58e324..0000000000 --- a/frontend/docs/pages/home/use-cases/_meta.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - "ai-agents": "AI Agents", - "rag-and-indexing": "RAG & Data Indexing", - "batch-processing": "Batch Processing", - "event-driven": "Event-Driven Systems", -}; diff --git a/frontend/docs/pages/home/use-cases/ai-agents.mdx b/frontend/docs/pages/home/use-cases/ai-agents.mdx deleted file mode 100644 index 6d56a17ffd..0000000000 --- a/frontend/docs/pages/home/use-cases/ai-agents.mdx +++ /dev/null @@ -1,100 +0,0 @@ -import { Callout, Cards, Steps } from "nextra/components"; -import AgentLoopDiagram from "@/components/AgentLoopDiagramWrapper"; - -# AI Agents - -AI agents follow a **reason-act-observe** loop that can run for minutes or hours. This page covers how to structure agent workflows in Hatchet so they survive interruptions, stream output to users, and don't exhaust resources. - - - -## What Makes Agents Different - - - -### Long-running loops - -An agent loop may iterate dozens of times, calling an LLM, parsing tool calls, executing tools, and evaluating results. Durable execution checkpoints each step, so if a worker restarts mid-loop the agent resumes from its last checkpoint instead of starting over. - -### Streaming output - -Agents typically need to stream LLM tokens to a frontend as they're generated. Use `put_stream()` in your task to emit chunks, and `subscribe_to_stream()` on the client to receive them. - -### Concurrency and cancellation - -`CANCEL_IN_PROGRESS` concurrency cancels stale agent runs when a user sends a new message. Rate limits prevent exceeding LLM provider API quotas. Priority queues let interactive agent tasks run ahead of batch work. - - - -## Key Features - -| Feature | What it does for agents | -| --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| **[Durable Execution](/home/durable-workflows-overview)** | Checkpoints tool calls, LLM responses, and loop iterations so agent state survives crashes | -| **[Streaming](/home/streaming)** | Stream LLM tokens from workers to frontends in real-time | -| **[Durable Events](/home/durable-events)** | Pause for human-in-the-loop feedback without holding worker slots | -| **[Durable Sleep](/home/durable-sleep)** | Schedule agent retries or delayed actions with exact timing | -| **[Concurrency](/home/concurrency)** | Cancel stale runs, limit parallel agents per user | -| **[Rate Limits](/home/rate-limits)** | Stay within LLM provider API rate limits across all workers | -| **[Child Spawning](/home/child-spawning)** | Agents dynamically spawn sub-agents or tool-call tasks | -| **[Cancellation](/home/cancellation)** | Stop long-running agents on user request | - - - The agent loop pattern is a [Cycle](/home/patterns/cycles). Each iteration - spawns a new child run via `RunChild`. Combined with [Durable - Execution](/home/durable-workflows-overview), completed iterations survive - crashes and slots are freed between iterations. - - -## Typical Agent Flow - - - -### Receive user input - -A trigger (API call, webhook, or event) starts the agent workflow with the user's message and conversation context. - -### Reasoning loop - -The agent enters a durable task loop: call the LLM, parse tool calls, execute tools via child tasks, observe results, and decide whether to continue or respond. - -### Stream response - -As the LLM generates its final response, tokens are streamed through Hatchet to the frontend in real-time. - -### Wait for next input - -The agent uses `WaitForEvent` to pause until the user sends another message, freeing the worker slot for other work. - - - - - Always set a **timeout** and **max iteration count** on agent loops. Without - bounds, an agent can loop indefinitely. See [Timeouts](/home/timeouts) for - configuration. - - -## Related Patterns - - - - The core loop pattern behind agent reasoning, where a task re-spawns itself - until a goal is met. - - - Pause agents for human feedback or scheduled retries without holding worker - slots. - - - Agents that spawn parallel tool calls or sub-agent tasks. - - - Route agent behavior based on LLM tool call decisions or user preferences. - - - -## Next Steps - -- [Durable Workflows](/home/durable-workflows-overview): understand checkpointing and replay -- [Streaming](/home/streaming): set up real-time LLM output streaming -- [Concurrency Control](/home/concurrency): configure CANCEL_IN_PROGRESS for chat agents -- [Child Spawning](/home/child-spawning): spawn tool-call tasks from agent loops diff --git a/frontend/docs/pages/home/workers.mdx b/frontend/docs/pages/home/workers.mdx deleted file mode 100644 index 528547234b..0000000000 --- a/frontend/docs/pages/home/workers.mdx +++ /dev/null @@ -1,170 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -# Workers - -Workers are the backbone of Hatchet, responsible for executing the individual tasks. They operate across different nodes in your infrastructure, allowing for distributed and scalable task execution. - -## How Workers Operate - -In Hatchet, workers are long-running processes that wait for instructions from the Hatchet engine to execute specific steps. They communicate with the Hatchet engine to receive tasks, execute them, and report back the results. - -## Declaring a Worker - -Now that we have a [task declared](./your-first-task.mdx) we can create a worker that can execute the task. - -Declare a worker by calling the `worker` method on the Hatchet client. The `worker` method takes a name and an optional configuration object. - - - - - - - If you are using Windows, attempting to run a worker will result in an error: - - ``` - AttributeError: module 'signal' has no attribute 'SIGQUIT' - ``` - - However you can use the [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) to run your workers. After - you install your Python environment (e.g. via `uv` or `poetry`) in WSL, you can then - run your workers inside that environment. You can still run client code (e.g. to - trigger task runs or query the API) in your native Windows environment, but your - workers have to be run in WSL. - - Another option is to run workers in Docker containers. - - - - - ### Register the Worker - - - - ### Add an Entrypoint Script - - Add a script to your `package.json` to start the worker (changing the file path to the location of your worker file): - - ```json - "scripts": { - "start:worker": "ts-node src/v1/examples/simple/worker.ts" - } - ``` - - ### Run the Worker - - Start the worker by running the script you just added to your `package.json`: - - - ```bash - npm run start:worker - ``` - - - ```bash - pnpm run start:worker - ``` - - - ```bash - yarn start:worker - ``` - - - - - - - - Then start the worker by running: - ```bash - go run main.go - ``` - - - Note there are both `worker.Start` and `worker.StartBlocking` methods. The `StartBlocking` method will block the main thread until the worker is stopped, while the `Start` method will return immediately and you'll need to call `worker.Stop` to stop the worker. - - - - - ### Add the Hatchet SDK to your Gemfile - - ```ruby - gem "hatchet-sdk" - ``` - - Then install with: - - ```bash - bundle install - ``` - - ### Register the Worker - - - - ### Run the Worker - - Start the worker by running: - - ```bash - bundle exec ruby worker.rb - ``` - - - - -And that's it! Once you run your script to start the worker, you'll see some logs like this, which tell you that your worker is running. - - - For self-hosted users, you may need to set other gRPC configuration options to - ensure your worker can connect to the Hatchet engine. See the - [Self-Hosting](../self-hosting/worker-configuration-options.mdx) docs for more - information. - - -``` -[DEBUG] 🪓 -- 2025-03-24 15:11:32,755 - creating new event loop -[INFO] 🪓 -- 2025-03-24 15:11:32,755 - ------------------------------------------ -[INFO] 🪓 -- 2025-03-24 15:11:32,755 - STARTING HATCHET... -[DEBUG] 🪓 -- 2025-03-24 15:11:32,755 - worker runtime starting on PID: 26406 -[DEBUG] 🪓 -- 2025-03-24 15:11:32,758 - action listener starting on PID: 26434 -[INFO] 🪓 -- 2025-03-24 15:11:32,760 - starting runner... -[DEBUG] 🪓 -- 2025-03-24 15:11:32,761 - starting action listener health check... -[DEBUG] 🪓 -- 2025-03-24 15:11:32,764 - 'test-worker' waiting for ['simpletask:step1'] -[DEBUG] 🪓 -- 2025-03-24 15:11:33,413 - starting action listener: test-worker -[DEBUG] 🪓 -- 2025-03-24 15:11:33,542 - acquired action listener: efc4aaf2-be4a-4964-a578-db6465f9297e -[DEBUG] 🪓 -- 2025-03-24 15:11:33,542 - sending heartbeat -[DEBUG] 🪓 -- 2025-03-24 15:11:37,658 - sending heartbeat -``` - - - Note that many of these logs are `debug` logs, which only are shown if the - `debug` option on the Hatchet client is set to `True` - - -## Understanding Slots - -Slots are the number of concurrent _task_ runs that a worker can execute, are are configured using the `slots` option on the worker. For instance, if you set `slots=5` on your worker, then your worker will be able to run five tasks concurrently before new tasks start needing to wait in the queue before being picked up. Increasing the number of `slots` on your worker, or the number of workers you run, will allow you to handle more concurrent work (and thus more throughput, in many cases). - -An important caveat is that slot-level concurrency is only helpful up to the point where the worker is not bottlenecked by another resource, such as CPU, memory, or network bandwidth. If your worker is bottlenecked by one of these resources, increasing the number of slots will not improve throughput. - -## Best Practices for Managing Workers - -To ensure a robust and efficient Hatchet implementation, consider the following best practices when managing your workers: - -1. **Reliability**: Deploy workers in a stable environment with sufficient resources to avoid resource contention and ensure reliable execution. - -2. **Monitoring and Logging**: Implement robust monitoring and logging mechanisms to track worker health, performance, and task execution status. - -3. **Error Handling**: Design workers to handle errors gracefully, report execution failures to Hatchet, and retry tasks based on configured policies. - -4. **Secure Communication**: Ensure secure communication between workers and the Hatchet engine, especially when distributed across different networks. - -5. **Lifecycle Management**: Implement proper lifecycle management for workers, including automatic restarts on critical failures and graceful shutdown procedures. - -6. **Scalability**: Plan for scalability by designing your system to easily add or remove workers based on demand, leveraging containerization, orchestration tools, or cloud auto-scaling features. - -7. **Consistent Updates**: Keep worker implementations up to date with the latest Hatchet SDKs and ensure compatibility with the Hatchet engine version. diff --git a/frontend/docs/pages/home/workflows-overview.mdx b/frontend/docs/pages/home/workflows-overview.mdx deleted file mode 100644 index d8cf339085..0000000000 --- a/frontend/docs/pages/home/workflows-overview.mdx +++ /dev/null @@ -1,67 +0,0 @@ -import { Callout, Cards, Steps } from "nextra/components"; -import WorkflowDiagram from "@/components/WorkflowDiagram"; - -# Workflows (DAGs) - -A **workflow** is a set of tasks connected by dependencies. You define the shape of work upfront (which tasks run, in what order, and what depends on what) and Hatchet handles execution, retries, and observability for you. - -Workflows in Hatchet are **Directed Acyclic Graphs (DAGs)**: each task is a node, dependencies are edges, and there are no circular paths. This structure makes workflows predictable, debuggable, and easy to reason about. - - - -## What a Workflow Gives You - - - -### Declarative task dependencies - -Define which tasks depend on which. Hatchet automatically executes tasks in the right order and parallelizes independent tasks. - -### Automatic retries and error handling - -Each task has configurable [retry policies](/home/retry-policies) and [timeouts](/home/timeouts). If a task fails, Hatchet retries it without re-running already-completed tasks. - -### Full observability - -Every task execution is tracked in the Hatchet dashboard, including inputs, outputs, durations, and errors. You can see exactly where a workflow succeeded or failed. - -### Cached intermediate results - -Task outputs are stored and passed to downstream tasks. If a workflow is partially complete when a failure occurs, completed tasks don't need to re-run. - - - -## Anatomy of a Workflow - -| Concept | Description | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Task** | An atomic unit of work. A function that takes input and returns output | -| **Dependency** | A parent-child relationship between tasks. A child task waits for its parents to complete | -| **Input** | Data passed to the workflow when it's triggered | -| **Output** | Data returned by each task, accessible to downstream tasks | -| **Trigger** | How a workflow starts: [direct run](/home/run-with-results), [event](/home/run-on-event), [cron](/home/cron-runs), [schedule](/home/scheduled-runs), or [webhooks](/home/webhooks) | - - - Workflows can be as simple as a single task or as complex as dozens of tasks - with branching, fan-out, and conditional logic. Start simple and compose as - needed. - - -## Next Steps - - - - Learn how durable workflows checkpoint, pause, and resume across failures. - - - Define multi-task workflows with dependencies, parent outputs, and parallel - execution. - - - Dynamically create child tasks at runtime for fan-out, loops, and agent - patterns. - - - Explore common workflow patterns: fanout, branching, cycles, and more. - - diff --git a/frontend/docs/pages/home/your-first-task.mdx b/frontend/docs/pages/home/your-first-task.mdx deleted file mode 100644 index 0b3dd3bcac..0000000000 --- a/frontend/docs/pages/home/your-first-task.mdx +++ /dev/null @@ -1,69 +0,0 @@ -import { snippets } from "@/lib/generated/snippets"; -import { Snippet } from "@/components/code"; -import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; -import UniversalTabs from "@/components/UniversalTabs"; - -# Declaring Your First Task - -In Hatchet, the fundamental unit of invocable work is a [Task](#defining-a-task). Each task is an atomic function. - -As we continue to build with Hatchet, we'll add additional configuration options to compose tasks into [DAG workflows](./dags.mdx) or [procedural child spawning](./child-spawning.mdx). - -## Defining a Task - -Start by declaring a task with a name. The task object can declare additional task-level configuration options which we'll cover later. - -The returned object is an instance of the `Task` class, which is the primary interface for interacting with the task (i.e. [running](./run-with-results.mdx), [enqueuing](./run-no-wait.mdx), [scheduling](./scheduled-runs.mdx), etc). - - - - - - - - - - - - - - - - - -## Running a Task - -With your task defined, you can import it wherever you need to use it and invoke it with the `run` method. - - - NOTE: You must first [register the task on a worker](./workers.mdx) before you - can run it. Calling `your_task.run` will enqueue a task to be executed by a - worker but it will wait indefinitely for the task to be executed. - - - - - - - - - - - - - - - - - - - -There are many ways to run a task, including: - -- [Running a task with results](./run-with-results.mdx) -- [Enqueuing a task](./run-no-wait.mdx) -- [Scheduling a task](./scheduled-runs.mdx) -- [Scheduling a task with a cron schedule](./cron-runs.mdx) -- [Event-driven task execution](./run-on-event.mdx) - -Now that you have defined a complete task, you can move on to [creating a worker to execute the task](./workers.mdx). diff --git a/frontend/docs/pages/migrating/_meta.js b/frontend/docs/pages/migrating/_meta.js new file mode 100644 index 0000000000..d3f923e544 --- /dev/null +++ b/frontend/docs/pages/migrating/_meta.js @@ -0,0 +1,3 @@ +export default { + "v0-to-v1": "V0 to V1 Upgrade Guide", +}; diff --git a/frontend/docs/pages/migrating/v0-to-v1/_meta.js b/frontend/docs/pages/migrating/v0-to-v1/_meta.js new file mode 100644 index 0000000000..4e9d1fe3d1 --- /dev/null +++ b/frontend/docs/pages/migrating/v0-to-v1/_meta.js @@ -0,0 +1,7 @@ +export default { + "v1-sdk-improvements": "SDK Improvements", + "migration-guide-engine": "Engine Migration Guide", + "migration-guide-python": "Python Migration Guide", + "migration-guide-typescript": "Typescript Migration Guide", + "migration-guide-go": "Go Migration Guide", +}; diff --git a/frontend/docs/pages/home/migration-guide-engine.mdx b/frontend/docs/pages/migrating/v0-to-v1/migration-guide-engine.mdx similarity index 100% rename from frontend/docs/pages/home/migration-guide-engine.mdx rename to frontend/docs/pages/migrating/v0-to-v1/migration-guide-engine.mdx diff --git a/frontend/docs/pages/home/migration-guide-go.mdx b/frontend/docs/pages/migrating/v0-to-v1/migration-guide-go.mdx similarity index 100% rename from frontend/docs/pages/home/migration-guide-go.mdx rename to frontend/docs/pages/migrating/v0-to-v1/migration-guide-go.mdx diff --git a/frontend/docs/pages/home/migration-guide-python.mdx b/frontend/docs/pages/migrating/v0-to-v1/migration-guide-python.mdx similarity index 100% rename from frontend/docs/pages/home/migration-guide-python.mdx rename to frontend/docs/pages/migrating/v0-to-v1/migration-guide-python.mdx diff --git a/frontend/docs/pages/home/migration-guide-typescript.mdx b/frontend/docs/pages/migrating/v0-to-v1/migration-guide-typescript.mdx similarity index 100% rename from frontend/docs/pages/home/migration-guide-typescript.mdx rename to frontend/docs/pages/migrating/v0-to-v1/migration-guide-typescript.mdx diff --git a/frontend/docs/pages/home/v1-sdk-improvements.mdx b/frontend/docs/pages/migrating/v0-to-v1/v1-sdk-improvements.mdx similarity index 100% rename from frontend/docs/pages/home/v1-sdk-improvements.mdx rename to frontend/docs/pages/migrating/v0-to-v1/v1-sdk-improvements.mdx diff --git a/frontend/docs/pages/patterns/_meta.js b/frontend/docs/pages/patterns/_meta.js new file mode 100644 index 0000000000..3f160eaf13 --- /dev/null +++ b/frontend/docs/pages/patterns/_meta.js @@ -0,0 +1,20 @@ +export default { + index: "Overview", + "--patterns": { + title: "Patterns", + type: "separator", + }, + fanout: "Fanout", + "pre-determined-pipelines": "Pre-Determined Pipelines", + branching: "Branching", + cycles: "Cycles", + "long-waits": "Long Waits", + "--use-cases": { + title: "Use Cases", + type: "separator", + }, + "rag-and-indexing": "RAG & Data Indexing", + "ai-agents": "AI Agents", + "batch-processing": "Batch Processing", + "event-driven": "Event-Driven Systems", +}; diff --git a/frontend/docs/pages/patterns/ai-agents.mdx b/frontend/docs/pages/patterns/ai-agents.mdx new file mode 100644 index 0000000000..3bd81afbc1 --- /dev/null +++ b/frontend/docs/pages/patterns/ai-agents.mdx @@ -0,0 +1,100 @@ +import { Callout, Cards, Steps } from "nextra/components"; +import AgentLoopDiagram from "@/components/AgentLoopDiagramWrapper"; + +# AI Agents + +AI agents follow a **reason-act-observe** loop that can run for minutes or hours. This page covers how to structure agent workflows in Hatchet so they survive interruptions, stream output to users, and don't exhaust resources. + + + +## What Makes Agents Different + + + +### Long-running loops + +An agent loop may iterate dozens of times, calling an LLM, parsing tool calls, executing tools, and evaluating results. Durable execution checkpoints each step, so if a worker restarts mid-loop the agent resumes from its last checkpoint instead of starting over. + +### Streaming output + +Agents typically need to stream LLM tokens to a frontend as they're generated. Use `put_stream()` in your task to emit chunks, and `subscribe_to_stream()` on the client to receive them. + +### Concurrency and cancellation + +`CANCEL_IN_PROGRESS` concurrency cancels stale agent runs when a user sends a new message. Rate limits prevent exceeding LLM provider API quotas. Priority queues let interactive agent tasks run ahead of batch work. + + + +## Key Features + +| Feature | What it does for agents | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **[Durable Execution](/concepts/durable-workflows/durable-task-execution)** | Checkpoints tool calls, LLM responses, and loop iterations so agent state survives crashes | +| **[Streaming](/concepts/streaming)** | Stream LLM tokens from workers to frontends in real-time | +| **[Durable Events](/concepts/durable-workflows/durable-task-execution/durable-events)** | Pause for human-in-the-loop feedback without holding worker slots | +| **[Durable Sleep](/concepts/durable-workflows/durable-task-execution/durable-sleep)** | Schedule agent retries or delayed actions with exact timing | +| **[Concurrency](/concepts/concurrency)** | Cancel stale runs, limit parallel agents per user | +| **[Rate Limits](/concepts/rate-limits)** | Stay within LLM provider API rate limits across all workers | +| **[Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning)** | Agents dynamically spawn sub-agents or tool-call tasks | +| **[Cancellation](/concepts/cancellation)** | Stop long-running agents on user request | + + + The agent loop pattern is a [Cycle](/patterns/cycles). Each iteration spawns a + new child run via `RunChild`. Combined with [Durable + Execution](/concepts/durable-workflows/durable-task-execution), completed + iterations survive crashes and slots are freed between iterations. + + +## Typical Agent Flow + + + +### Receive user input + +A trigger (API call, webhook, or event) starts the agent workflow with the user's message and conversation context. + +### Reasoning loop + +The agent enters a durable task loop: call the LLM, parse tool calls, execute tools via child tasks, observe results, and decide whether to continue or respond. + +### Stream response + +As the LLM generates its final response, tokens are streamed through Hatchet to the frontend in real-time. + +### Wait for next input + +The agent uses `WaitForEvent` to pause until the user sends another message, freeing the worker slot for other work. + + + + + Always set a **timeout** and **max iteration count** on agent loops. Without + bounds, an agent can loop indefinitely. See [Timeouts](/concepts/timeouts) for + configuration. + + +## Related Patterns + + + + The core loop pattern behind agent reasoning, where a task re-spawns itself + until a goal is met. + + + Pause agents for human feedback or scheduled retries without holding worker + slots. + + + Agents that spawn parallel tool calls or sub-agent tasks. + + + Route agent behavior based on LLM tool call decisions or user preferences. + + + +## Next Steps + +- [Durable Workflows](/concepts/durable-workflows/durable-task-execution): understand checkpointing and replay +- [Streaming](/concepts/streaming): set up real-time LLM output streaming +- [Concurrency Control](/concepts/concurrency): configure CANCEL_IN_PROGRESS for chat agents +- [Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): spawn tool-call tasks from agent loops diff --git a/frontend/docs/pages/home/use-cases/batch-processing.mdx b/frontend/docs/pages/patterns/batch-processing.mdx similarity index 56% rename from frontend/docs/pages/home/use-cases/batch-processing.mdx rename to frontend/docs/pages/patterns/batch-processing.mdx index 467dd955c1..3d6f43a872 100644 --- a/frontend/docs/pages/home/use-cases/batch-processing.mdx +++ b/frontend/docs/pages/patterns/batch-processing.mdx @@ -27,22 +27,21 @@ Concurrency limits prevent overwhelming your infrastructure. Rate limits protect ## Key Features -| Feature | What it does for batch processing | -| -------------------------------------------------------- | ----------------------------------------------------------------------- | -| **[Child Spawning](/home/child-spawning)** | Fan out to one task per item with automatic distribution across workers | -| **[Bulk Run](/home/bulk-run)** | Trigger thousands of tasks in a single API call | -| **[Retry Policies](/home/retry-policies)** | Retry failed items individually without restarting the batch | -| **[Bulk Retries](/home/bulk-retries-and-cancellations)** | Re-run all failed items from the dashboard | -| **[Concurrency](/home/concurrency)** | Limit how many items process simultaneously | -| **[Rate Limits](/home/rate-limits)** | Throttle external API calls across all workers | -| **[Priority](/home/priority)** | Urgent batches jump ahead of lower-priority work | -| **[Autoscaling](/home/autoscaling-workers)** | Scale workers up during batch processing, down when idle | +| Feature | What it does for batch processing | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| **[Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning)** | Fan out to one task per item with automatic distribution across workers | +| **[Bulk Run](/concepts/bulk-run)** | Trigger thousands of tasks in a single API call | +| **[Retry Policies](/concepts/retry-policies)** | Retry failed items individually without restarting the batch | +| **[Bulk Retries](/concepts/bulk-retries-and-cancellations)** | Re-run all failed items from the dashboard | +| **[Concurrency](/concepts/concurrency)** | Limit how many items process simultaneously | +| **[Rate Limits](/concepts/rate-limits)** | Throttle external API calls across all workers | +| **[Priority](/concepts/priority)** | Urgent batches jump ahead of lower-priority work | +| **[Autoscaling](/essentials/autoscaling-workers)** | Scale workers up during batch processing, down when idle | - The batch processing pattern is [Fanout](/home/patterns/fanout) applied at - scale. For fixed multi-stage processing (e.g., validate → transform → load), - combine with [Pre-Determined - Pipelines](/home/patterns/pre-determined-pipelines). + The batch processing pattern is [Fanout](/patterns/fanout) applied at scale. + For fixed multi-stage processing (e.g., validate → transform → load), combine + with [Pre-Determined Pipelines](/patterns/pre-determined-pipelines). ## Architecture @@ -70,7 +69,8 @@ The parent awaits all children and aggregates results. You can see the status of For batches with thousands of items, use **durable workflows** so the parent task doesn't hold a worker slot while waiting for all children to complete. - See [Durable Workflows](/home/durable-workflows-overview) for details. + See [Durable Workflows](/concepts/durable-workflows/durable-task-execution) + for details. ## Common Batch Patterns @@ -86,26 +86,26 @@ The parent awaits all children and aggregates results. You can see the status of ## Related Patterns - + The core pattern behind batch processing, spawning N children from a parent. Chain batch processing with multi-stage transforms in a DAG. - + A specialized batch processing use case for document indexing pipelines. - + Process paginated results one page at a time with iterative child spawning. ## Next Steps -- [Child Spawning](/home/child-spawning): learn the fan-out API for batch processing -- [Bulk Run](/home/bulk-run): trigger large batches efficiently -- [Concurrency Control](/home/concurrency): limit concurrent item processing -- [Rate Limits](/home/rate-limits): protect external APIs during batch operations +- [Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): learn the fan-out API for batch processing +- [Bulk Run](/concepts/bulk-run): trigger large batches efficiently +- [Concurrency Control](/concepts/concurrency): limit concurrent item processing +- [Rate Limits](/concepts/rate-limits): protect external APIs during batch operations diff --git a/frontend/docs/pages/home/patterns/branching.mdx b/frontend/docs/pages/patterns/branching.mdx similarity index 67% rename from frontend/docs/pages/home/patterns/branching.mdx rename to frontend/docs/pages/patterns/branching.mdx index 4679f1ce8d..e9babc54b0 100644 --- a/frontend/docs/pages/home/patterns/branching.mdx +++ b/frontend/docs/pages/patterns/branching.mdx @@ -43,10 +43,13 @@ The branch whose condition is satisfied runs. | `cancel_if` | Task and its downstream dependents are cancelled if the condition evaluates to true | - Branching is built using [Conditional Workflows](/home/conditional-workflows). - See that page for full code examples covering parent conditions, event - conditions, sleep conditions, or groups, and a complete branching workflow - walkthrough. + Branching is built using conditions on DAG tasks. See [Parent + Conditions](/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions), + [Event + Conditions](/concepts/durable-workflows/directed-acyclic-graphs/event-conditions), + and [Sleep + Conditions](/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions) + for full code examples covering each condition type, or groups, and operators. @@ -60,24 +63,30 @@ The branch whose condition is satisfied runs. ## Use Cases - + Read a feature flag in an upstream task and route the workflow down different code paths based on its value. Pause workflow execution until a human approves or rejects, then branch accordingly. - + Route data through different processing paths based on experiment assignment. Branch into a fallback path when an upstream task signals a degraded state. @@ -85,7 +94,8 @@ The branch whose condition is satisfied runs. ## Next Steps -- [Conditional Workflows](/home/conditional-workflows): full guide to conditions, or groups, and operators -- [DAG Workflows](/home/dags): define task dependencies that branching builds on -- [Fanout](/home/patterns/fanout): dynamically spawn tasks instead of choosing between fixed branches -- [Pre-Determined Pipelines](/home/patterns/pre-determined-pipelines): fixed-structure pipelines without branching +- [Parent Conditions](/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions): branch based on parent task output +- [Event Conditions](/concepts/durable-workflows/directed-acyclic-graphs/event-conditions): react to external events, or groups, and operators +- [DAG Workflows](/concepts/durable-workflows/directed-acyclic-graphs): define task dependencies that branching builds on +- [Fanout](/patterns/fanout): dynamically spawn tasks instead of choosing between fixed branches +- [Pre-Determined Pipelines](/patterns/pre-determined-pipelines): fixed-structure pipelines without branching diff --git a/frontend/docs/pages/home/patterns/cycles.mdx b/frontend/docs/pages/patterns/cycles.mdx similarity index 70% rename from frontend/docs/pages/home/patterns/cycles.mdx rename to frontend/docs/pages/patterns/cycles.mdx index 5c3f004e36..412a4cccfd 100644 --- a/frontend/docs/pages/home/patterns/cycles.mdx +++ b/frontend/docs/pages/patterns/cycles.mdx @@ -27,9 +27,10 @@ If the condition is not met, the task spawns a new child run of itself with upda - Cycles use [Procedural Child Spawning](/home/child-spawning) under the hood; - each iteration is a new child task run. This gives you full observability into - every iteration in the Hatchet dashboard. + Cycles use [Procedural Child + Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning) + under the hood; each iteration is a new child task run. This gives you full + observability into every iteration in the Hatchet dashboard. ## When to Use Cycles @@ -53,18 +54,27 @@ If the condition is not met, the task spawns a new child run of itself with upda ## Use Cases - + Build agents that reason, take action, observe results, and loop until they achieve their goal. - + Check an external service repeatedly until a resource is ready or a status changes. - + Process data in successive passes, refining results until convergence. - + Implement application-specific retry strategies with state carried between attempts. @@ -72,7 +82,7 @@ If the condition is not met, the task spawns a new child run of itself with upda ## Next Steps -- [Procedural Child Spawning](/home/child-spawning): the mechanism that powers cycle iteration -- [Retry Policies](/home/retry-policies): built-in retry support for simpler retry scenarios -- [Branching](/home/patterns/branching): combine cycles with conditional logic -- [Fanout](/home/patterns/fanout): spawn parallel tasks instead of sequential iterations +- [Procedural Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): the mechanism that powers cycle iteration +- [Retry Policies](/concepts/retry-policies): built-in retry support for simpler retry scenarios +- [Branching](/patterns/branching): combine cycles with conditional logic +- [Fanout](/patterns/fanout): spawn parallel tasks instead of sequential iterations diff --git a/frontend/docs/pages/home/use-cases/event-driven.mdx b/frontend/docs/pages/patterns/event-driven.mdx similarity index 61% rename from frontend/docs/pages/home/use-cases/event-driven.mdx rename to frontend/docs/pages/patterns/event-driven.mdx index 1c6db19e0c..1628f37f58 100644 --- a/frontend/docs/pages/home/use-cases/event-driven.mdx +++ b/frontend/docs/pages/patterns/event-driven.mdx @@ -27,21 +27,21 @@ Hatchet matches events to registered task triggers, handles deduplication and fi ## Key Features -| Feature | What it does for event-driven systems | -| -------------------------------------------------------------- | ------------------------------------------------------------------- | -| **[Event Triggers](/home/run-on-event)** | React to application events by name with optional payload filtering | -| **[Webhooks](/home/webhooks)** | Receive external HTTP webhooks and route them to tasks | -| **[Cron Triggers](/home/cron-runs)** | Schedule recurring tasks with cron expressions | -| **[Scheduled Runs](/home/scheduled-runs)** | Trigger one-time future execution at a specific time | -| **[Concurrency](/home/concurrency)** | Spike protection with CANCEL_NEWEST or CANCEL_OLDEST strategies | -| **[Rate Limits](/home/rate-limits)** | Throttle downstream processing regardless of event volume | -| **[Inter-Service Triggering](/home/inter-service-triggering)** | Trigger tasks across different services and worker pools | -| **[Additional Metadata](/home/additional-metadata)** | Tag events with metadata for filtering and observability | +| Feature | What it does for event-driven systems | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **[Event Triggers](/concepts/run-on-event)** | React to application events by name with optional payload filtering | +| **[Webhooks](/concepts/webhooks)** | Receive external HTTP webhooks and route them to tasks | +| **[Cron Triggers](/concepts/cron-runs)** | Schedule recurring tasks with cron expressions | +| **[Scheduled Runs](/concepts/scheduled-runs)** | Trigger one-time future execution at a specific time | +| **[Concurrency](/concepts/concurrency)** | Spike protection with CANCEL_NEWEST or CANCEL_OLDEST strategies | +| **[Rate Limits](/concepts/rate-limits)** | Throttle downstream processing regardless of event volume | +| **[Inter-Service Triggering](/concepts/inter-service-triggering)** | Trigger tasks across different services and worker pools | +| **[Additional Metadata](/concepts/durable-workflows/directed-acyclic-graphs/additional-metadata)** | Tag events with metadata for filtering and observability | Hatchet supports all common triggering patterns in a single system. See [Ways - of Running Tasks](/home/running-tasks) for the complete list of trigger types - and when to use each. + of Running Tasks](/concepts/running-tasks) for the complete list of trigger + types and when to use each. ## Architecture @@ -85,18 +85,18 @@ Task outputs can trigger further events, spawn child tasks, or start downstream ## Related Patterns - + Route event-driven workflows down different paths based on event payload. - + A single event triggers parallel processing across multiple workers. - + Pause a workflow until an external event arrives, then resume. Chain event-triggered tasks into multi-stage processing pipelines. @@ -104,7 +104,7 @@ Task outputs can trigger further events, spawn child tasks, or start downstream ## Next Steps -- [Event Triggers](/home/run-on-event): trigger tasks from application events -- [Webhooks](/home/webhooks): receive and process external webhooks -- [Cron Triggers](/home/cron-runs): set up recurring scheduled tasks -- [Concurrency Control](/home/concurrency): configure spike protection +- [Event Triggers](/concepts/run-on-event): trigger tasks from application events +- [Webhooks](/concepts/webhooks): receive and process external webhooks +- [Cron Triggers](/concepts/cron-runs): set up recurring scheduled tasks +- [Concurrency Control](/concepts/concurrency): configure spike protection diff --git a/frontend/docs/pages/home/patterns/fanout.mdx b/frontend/docs/pages/patterns/fanout.mdx similarity index 73% rename from frontend/docs/pages/home/patterns/fanout.mdx rename to frontend/docs/pages/patterns/fanout.mdx index cfd63cd6d0..ea39ac8a5a 100644 --- a/frontend/docs/pages/home/patterns/fanout.mdx +++ b/frontend/docs/pages/patterns/fanout.mdx @@ -27,15 +27,16 @@ The parent may choose to await all child completions and aggregate their results - Fanout is built on top of [Procedural Child Spawning](/home/child-spawning). + Fanout is built on top of [Procedural Child + Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning). See that page for full code examples on defining tasks, spawning children, parallel execution, and error handling. When fanning out to large numbers of children, make sure your workers have - enough slots to handle the concurrent load. See [Worker Slots](/home/workers) - for details on configuring slot capacity. + enough slots to handle the concurrent load. See [Worker + Slots](/essentials/workers) for details on configuring slot capacity. ## In Workflows vs Durable Workflows @@ -71,18 +72,24 @@ The parent may choose to await all child completions and aggregate their results ## Use Cases - + Process thousands of items (images, documents, records) in parallel with automatic load distribution across workers. - + Fan out to map tasks, then reduce results in a downstream DAG step. - + Send the same prompt to multiple LLM providers concurrently and pick the best response. - + Fan out to send notifications to N users while respecting per-provider rate limits. @@ -90,7 +97,7 @@ The parent may choose to await all child completions and aggregate their results ## Next Steps -- [Procedural Child Spawning](/home/child-spawning): learn the full child task API -- [DAG Workflows](/home/dags): combine fanout with declarative task dependencies -- [Concurrency Control](/home/concurrency): limit how many children run simultaneously -- [Rate Limits](/home/rate-limits): throttle child task execution rates +- [Procedural Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): learn the full child task API +- [DAG Workflows](/concepts/durable-workflows/directed-acyclic-graphs): combine fanout with declarative task dependencies +- [Concurrency Control](/concepts/concurrency): limit how many children run simultaneously +- [Rate Limits](/concepts/rate-limits): throttle child task execution rates diff --git a/frontend/docs/pages/patterns/index.mdx b/frontend/docs/pages/patterns/index.mdx new file mode 100644 index 0000000000..17023c973f --- /dev/null +++ b/frontend/docs/pages/patterns/index.mdx @@ -0,0 +1,5 @@ +# Patterns & Use Cases + +This section covers common workflow **patterns** (Fanout, Pre-Determined Pipelines, Branching, Cycles, Long Waits) and **use cases** (RAG & Indexing, AI Agents, Batch Processing, Event-Driven Systems). + +Use the sidebar to explore individual patterns and use cases. diff --git a/frontend/docs/pages/home/patterns/long-waits.mdx b/frontend/docs/pages/patterns/long-waits.mdx similarity index 65% rename from frontend/docs/pages/home/patterns/long-waits.mdx rename to frontend/docs/pages/patterns/long-waits.mdx index 68791ecad3..cbc1196a01 100644 --- a/frontend/docs/pages/home/patterns/long-waits.mdx +++ b/frontend/docs/pages/patterns/long-waits.mdx @@ -28,8 +28,11 @@ When the sleep expires or the event arrives, Hatchet re-schedules the task on an Long waits require **durable tasks**. Regular tasks cannot pause and resume - across worker restarts. See [Durable Sleep](/home/durable-sleep) and [Durable - Events](/home/durable-events) for API details. + across worker restarts. See [Durable + Sleep](/concepts/durable-workflows/durable-task-execution/durable-sleep) and + [Durable + Events](/concepts/durable-workflows/durable-task-execution/durable-events) for + API details. ## Types of Long Waits @@ -49,19 +52,31 @@ When the sleep expires or the event arrives, Hatchet re-schedules the task on an ## Use Cases - + Send a follow-up email or push notification hours or days after an initial action. - + Pause a workflow until a human approves, rejects, or provides input via an external event. - + Implement drip campaigns, trial expiration checks, or periodic status polls with precise timing. - + Wait for a webhook, payment confirmation, or third-party API callback before proceeding. @@ -69,7 +84,8 @@ When the sleep expires or the event arrives, Hatchet re-schedules the task on an ## Next Steps -- [Durable Sleep](/home/durable-sleep): pause for a fixed duration with exact timing guarantees -- [Durable Events](/home/durable-events): wait for external signals with optional CEL filters -- [Conditional Workflows](/home/conditional-workflows): combine long waits with branching logic -- [Cycles](/home/patterns/cycles): use long waits inside iterative loops +- [Durable Sleep](/concepts/durable-workflows/durable-task-execution/durable-sleep): pause for a fixed duration with exact timing guarantees +- [Durable Events](/concepts/durable-workflows/durable-task-execution/durable-events): wait for external signals with optional CEL filters +- [Event Conditions](/concepts/durable-workflows/directed-acyclic-graphs/event-conditions): combine long waits with branching logic +- [Sleep Conditions](/concepts/durable-workflows/directed-acyclic-graphs/sleep-conditions): delay tasks for a fixed duration in a DAG +- [Cycles](/patterns/cycles): use long waits inside iterative loops diff --git a/frontend/docs/pages/home/patterns/pre-determined-pipelines.mdx b/frontend/docs/pages/patterns/pre-determined-pipelines.mdx similarity index 64% rename from frontend/docs/pages/home/patterns/pre-determined-pipelines.mdx rename to frontend/docs/pages/patterns/pre-determined-pipelines.mdx index 063e70cf6c..e1022960ea 100644 --- a/frontend/docs/pages/home/patterns/pre-determined-pipelines.mdx +++ b/frontend/docs/pages/patterns/pre-determined-pipelines.mdx @@ -4,7 +4,7 @@ import PatternComparison from "@/components/PatternComparison"; # Pre-Determined Pipelines -A **pre-determined pipeline** is a workflow where the sequence of tasks and their dependencies are defined ahead of time as a Directed Acyclic Graph (DAG). Unlike [fanout](/home/patterns/fanout), where children are spawned dynamically at runtime, pipelines have a fixed structure that is known before execution begins. +A **pre-determined pipeline** is a workflow where the sequence of tasks and their dependencies are defined ahead of time as a Directed Acyclic Graph (DAG). Unlike [fanout](/patterns/fanout), where children are spawned dynamically at runtime, pipelines have a fixed structure that is known before execution begins. @@ -27,9 +27,10 @@ Tasks can read the outputs of their parent tasks through the context object. Thi - Pre-determined pipelines are built using [DAG Workflows](/home/dags). See that - page for full code examples on defining workflows, adding tasks with - dependencies, accessing parent outputs, and running workflows. + Pre-determined pipelines are built using [DAG + Workflows](/concepts/durable-workflows/directed-acyclic-graphs). See that page + for full code examples on defining workflows, adding tasks with dependencies, + accessing parent outputs, and running workflows. ## When to Use Pipelines vs Fanout @@ -46,19 +47,31 @@ Tasks can read the outputs of their parent tasks through the context object. Thi ## Use Cases - + Extract data, transform it through multiple stages, and load it into a destination, each stage as a task with clear dependencies. - + Build, test, and deploy in sequence with parallel test suites that fan back into a deploy step. - + Parse, validate, enrich, and index documents through a fixed sequence of processing stages. - + Chain LLM calls with validation gates (generate, evaluate, refine) where each step depends on the previous. @@ -66,7 +79,7 @@ Tasks can read the outputs of their parent tasks through the context object. Thi ## Next Steps -- [DAG Workflows](/home/dags): full guide to defining workflows with task dependencies -- [Fanout](/home/patterns/fanout): dynamically spawn tasks at runtime instead -- [Concurrency Control](/home/concurrency): limit concurrent task execution within a pipeline -- [Procedural Child Spawning](/home/child-spawning): combine pipelines with dynamic child tasks +- [DAG Workflows](/concepts/durable-workflows/directed-acyclic-graphs): full guide to defining workflows with task dependencies +- [Fanout](/patterns/fanout): dynamically spawn tasks at runtime instead +- [Concurrency Control](/concepts/concurrency): limit concurrent task execution within a pipeline +- [Procedural Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): combine pipelines with dynamic child tasks diff --git a/frontend/docs/pages/home/use-cases/rag-and-indexing.mdx b/frontend/docs/pages/patterns/rag-and-indexing.mdx similarity index 53% rename from frontend/docs/pages/home/use-cases/rag-and-indexing.mdx rename to frontend/docs/pages/patterns/rag-and-indexing.mdx index 9e61604961..d67d52c142 100644 --- a/frontend/docs/pages/home/use-cases/rag-and-indexing.mdx +++ b/frontend/docs/pages/patterns/rag-and-indexing.mdx @@ -27,22 +27,22 @@ If a task fails (embedding API timeout, database write error), Hatchet retries j ## Key Features -| Feature | What it does for RAG | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| **[Child Spawning](/home/child-spawning)** | Fan out to one task per document/chunk; process thousands in parallel | -| **[Rate Limits](/home/rate-limits)** | Throttle embedding API calls across all workers to stay within provider limits | -| **[Retry Policies](/home/retry-policies)** | Retry failed chunks individually without re-processing the whole pipeline | -| **[Concurrency](/home/concurrency)** | Fair scheduling with GROUP_ROUND_ROBIN for multi-tenant indexing | -| **[DAG Workflows](/home/dags)** | Chain pipeline stages (ingest → chunk → embed → index) with clear dependencies | -| **[Event Triggers](/home/run-on-event)** | Start indexing automatically when new documents arrive | -| **[Cron Triggers](/home/cron-runs)** | Schedule periodic re-indexing or incremental updates | -| **[Bulk Run](/home/bulk-run)** | Trigger indexing for thousands of documents in a single API call | +| Feature | What it does for RAG | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **[Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning)** | Fan out to one task per document/chunk; process thousands in parallel | +| **[Rate Limits](/concepts/rate-limits)** | Throttle embedding API calls across all workers to stay within provider limits | +| **[Retry Policies](/concepts/retry-policies)** | Retry failed chunks individually without re-processing the whole pipeline | +| **[Concurrency](/concepts/concurrency)** | Fair scheduling with GROUP_ROUND_ROBIN for multi-tenant indexing | +| **[DAG Workflows](/concepts/durable-workflows/directed-acyclic-graphs)** | Chain pipeline stages (ingest → chunk → embed → index) with clear dependencies | +| **[Event Triggers](/concepts/run-on-event)** | Start indexing automatically when new documents arrive | +| **[Cron Triggers](/concepts/cron-runs)** | Schedule periodic re-indexing or incremental updates | +| **[Bulk Run](/concepts/bulk-run)** | Trigger indexing for thousands of documents in a single API call | RAG pipelines are a natural fit for [Pre-Determined - Pipelines](/home/patterns/pre-determined-pipelines). The stages (ingest, - chunk, embed, index) are fixed and map directly to a DAG. Use - [Fanout](/home/patterns/fanout) within the chunking stage to parallelize. + Pipelines](/patterns/pre-determined-pipelines). The stages (ingest, chunk, + embed, index) are fixed and map directly to a DAG. Use + [Fanout](/patterns/fanout) within the chunking stage to parallelize. ## Pipeline Architecture @@ -73,7 +73,8 @@ A separate workflow handles user queries, generating query embeddings, searching When fanning out to many chunks, ensure your workers have enough slots or use - [Concurrency Control](/home/concurrency) to limit how many run simultaneously. + [Concurrency Control](/concepts/concurrency) to limit how many run + simultaneously. ## Multi-Tenant Indexing @@ -89,25 +90,25 @@ For SaaS applications where multiple tenants share the same pipeline: The fixed-stage DAG pattern that RAG pipelines are built on. - + Parallelize document and chunk processing across your worker fleet. - + Implement incremental indexing that re-crawls until all changes are processed. - + General-purpose batch processing patterns that apply to indexing workloads. ## Next Steps -- [DAG Workflows](/home/dags): define multi-stage pipelines with task dependencies -- [Rate Limits](/home/rate-limits): configure rate limiting for embedding APIs -- [Child Spawning](/home/child-spawning): fan out to per-document tasks -- [Concurrency Control](/home/concurrency): fair scheduling for multi-tenant indexing +- [DAG Workflows](/concepts/durable-workflows/directed-acyclic-graphs): define multi-stage pipelines with task dependencies +- [Rate Limits](/concepts/rate-limits): configure rate limiting for embedding APIs +- [Child Spawning](/concepts/durable-workflows/directed-acyclic-graphs/child-spawning): fan out to per-document tasks +- [Concurrency Control](/concepts/concurrency): fair scheduling for multi-tenant indexing diff --git a/frontend/docs/pages/sdks/_meta.js b/frontend/docs/pages/reference/_meta.js similarity index 54% rename from frontend/docs/pages/sdks/_meta.js rename to frontend/docs/pages/reference/_meta.js index d7875d1de6..c5d95d03db 100644 --- a/frontend/docs/pages/sdks/_meta.js +++ b/frontend/docs/pages/reference/_meta.js @@ -6,4 +6,11 @@ export default { toc: true, }, }, + cli: { + title: "CLI Reference", + type: "page", + theme: { + toc: true, + }, + }, }; diff --git a/frontend/docs/pages/cli/_meta.js b/frontend/docs/pages/reference/cli/_meta.js similarity index 100% rename from frontend/docs/pages/cli/_meta.js rename to frontend/docs/pages/reference/cli/_meta.js diff --git a/frontend/docs/pages/cli/index.mdx b/frontend/docs/pages/reference/cli/index.mdx similarity index 100% rename from frontend/docs/pages/cli/index.mdx rename to frontend/docs/pages/reference/cli/index.mdx diff --git a/frontend/docs/pages/cli/profiles.mdx b/frontend/docs/pages/reference/cli/profiles.mdx similarity index 100% rename from frontend/docs/pages/cli/profiles.mdx rename to frontend/docs/pages/reference/cli/profiles.mdx diff --git a/frontend/docs/pages/cli/running-hatchet-locally.mdx b/frontend/docs/pages/reference/cli/running-hatchet-locally.mdx similarity index 100% rename from frontend/docs/pages/cli/running-hatchet-locally.mdx rename to frontend/docs/pages/reference/cli/running-hatchet-locally.mdx diff --git a/frontend/docs/pages/cli/running-workers-locally.mdx b/frontend/docs/pages/reference/cli/running-workers-locally.mdx similarity index 100% rename from frontend/docs/pages/cli/running-workers-locally.mdx rename to frontend/docs/pages/reference/cli/running-workers-locally.mdx diff --git a/frontend/docs/pages/cli/triggering-workflows.mdx b/frontend/docs/pages/reference/cli/triggering-workflows.mdx similarity index 100% rename from frontend/docs/pages/cli/triggering-workflows.mdx rename to frontend/docs/pages/reference/cli/triggering-workflows.mdx diff --git a/frontend/docs/pages/cli/tui.mdx b/frontend/docs/pages/reference/cli/tui.mdx similarity index 100% rename from frontend/docs/pages/cli/tui.mdx rename to frontend/docs/pages/reference/cli/tui.mdx diff --git a/frontend/docs/pages/reference/python/_meta.js b/frontend/docs/pages/reference/python/_meta.js new file mode 100644 index 0000000000..c93c38e007 --- /dev/null +++ b/frontend/docs/pages/reference/python/_meta.js @@ -0,0 +1,63 @@ +export default { + client: { + title: "Client", + theme: { + toc: true, + }, + }, + + context: { + title: "Context", + theme: { + toc: true, + }, + }, + + "feature-clients": { + title: "Feature Clients", + theme: { + toc: true, + }, + }, + + runnables: { + title: "Runnables", + theme: { + toc: true, + }, + }, + "--python-specifics": { + title: "Python Specifics", + type: "separator", + }, + asyncio: { + title: "Asyncio", + theme: { + toc: true, + }, + }, + pydantic: { + title: "Pydantic", + theme: { + toc: true, + }, + }, + lifespans: { + title: "Lifespans", + theme: { + toc: true, + }, + }, + "dependency-injection": { + title: "Dependency Injection", + theme: { + toc: true, + }, + }, + dataclasses: { + title: "Dataclass Support", + theme: { + toc: true, + }, + }, +}; diff --git a/frontend/docs/pages/home/asyncio.mdx b/frontend/docs/pages/reference/python/asyncio.mdx similarity index 100% rename from frontend/docs/pages/home/asyncio.mdx rename to frontend/docs/pages/reference/python/asyncio.mdx diff --git a/frontend/docs/pages/sdks/python/client.mdx b/frontend/docs/pages/reference/python/client.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/client.mdx rename to frontend/docs/pages/reference/python/client.mdx diff --git a/frontend/docs/pages/sdks/python/context.mdx b/frontend/docs/pages/reference/python/context.mdx similarity index 96% rename from frontend/docs/pages/sdks/python/context.mdx rename to frontend/docs/pages/reference/python/context.mdx index dd45e68658..5716d5584d 100644 --- a/frontend/docs/pages/sdks/python/context.mdx +++ b/frontend/docs/pages/reference/python/context.mdx @@ -11,18 +11,18 @@ There are two types of context classes you'll encounter: ### Methods -| Name | Description | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `was_skipped` | Check if a given task was skipped. You can read about skipping in [the docs](../../home/conditional-workflows#skip_if). | -| `task_output` | Get the output of a parent task in a DAG. | -| `cancel` | Cancel the current task run. This will call the Hatchet API to cancel the step run and set the exit flag to True. | -| `aio_cancel` | Cancel the current task run. This will call the Hatchet API to cancel the step run and set the exit flag to True. | -| `done` | Check if the current task run has been cancelled. | -| `log` | Log a line to the Hatchet API. This will send the log line to the Hatchet API and return immediately. | -| `release_slot` | Manually release the slot for the current step run to free up a slot on the worker. Note that this is an advanced feature and should be used with caution. | -| `put_stream` | Put a stream event to the Hatchet API. This will send the data to the Hatchet API and return immediately. You can then subscribe to the stream from a separate consumer. | -| `refresh_timeout` | Refresh the timeout for the current task run. You can read about refreshing timeouts in [the docs](../../home/timeouts#refreshing-timeouts). | -| `fetch_task_run_error` | **DEPRECATED**: Use `get_task_run_error` instead. | +| Name | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `was_skipped` | Check if a given task was skipped. You can read about skipping in [the docs](/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions#checking-if-a-task-was-skipped). | +| `task_output` | Get the output of a parent task in a DAG. | +| `cancel` | Cancel the current task run. This will call the Hatchet API to cancel the step run and set the exit flag to True. | +| `aio_cancel` | Cancel the current task run. This will call the Hatchet API to cancel the step run and set the exit flag to True. | +| `done` | Check if the current task run has been cancelled. | +| `log` | Log a line to the Hatchet API. This will send the log line to the Hatchet API and return immediately. | +| `release_slot` | Manually release the slot for the current step run to free up a slot on the worker. Note that this is an advanced feature and should be used with caution. | +| `put_stream` | Put a stream event to the Hatchet API. This will send the data to the Hatchet API and return immediately. You can then subscribe to the stream from a separate consumer. | +| `refresh_timeout` | Refresh the timeout for the current task run. You can read about refreshing timeouts in [the docs](../../home/timeouts#refreshing-timeouts). | +| `fetch_task_run_error` | **DEPRECATED**: Use `get_task_run_error` instead. | ### Attributes @@ -146,7 +146,7 @@ Returns: #### `was_skipped` -Check if a given task was skipped. You can read about skipping in [the docs](../../home/conditional-workflows#skip_if). +Check if a given task was skipped. You can read about skipping in [the docs](/concepts/durable-workflows/directed-acyclic-graphs/parent-conditions#checking-if-a-task-was-skipped). Parameters: diff --git a/frontend/docs/pages/home/dataclasses.mdx b/frontend/docs/pages/reference/python/dataclasses.mdx similarity index 100% rename from frontend/docs/pages/home/dataclasses.mdx rename to frontend/docs/pages/reference/python/dataclasses.mdx diff --git a/frontend/docs/pages/home/dependency-injection.mdx b/frontend/docs/pages/reference/python/dependency-injection.mdx similarity index 100% rename from frontend/docs/pages/home/dependency-injection.mdx rename to frontend/docs/pages/reference/python/dependency-injection.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/_meta.js b/frontend/docs/pages/reference/python/feature-clients/_meta.js similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/_meta.js rename to frontend/docs/pages/reference/python/feature-clients/_meta.js diff --git a/frontend/docs/pages/sdks/python/feature-clients/cron.mdx b/frontend/docs/pages/reference/python/feature-clients/cron.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/cron.mdx rename to frontend/docs/pages/reference/python/feature-clients/cron.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/filters.mdx b/frontend/docs/pages/reference/python/feature-clients/filters.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/filters.mdx rename to frontend/docs/pages/reference/python/feature-clients/filters.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/logs.mdx b/frontend/docs/pages/reference/python/feature-clients/logs.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/logs.mdx rename to frontend/docs/pages/reference/python/feature-clients/logs.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/metrics.mdx b/frontend/docs/pages/reference/python/feature-clients/metrics.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/metrics.mdx rename to frontend/docs/pages/reference/python/feature-clients/metrics.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/rate_limits.mdx b/frontend/docs/pages/reference/python/feature-clients/rate_limits.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/rate_limits.mdx rename to frontend/docs/pages/reference/python/feature-clients/rate_limits.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/runs.mdx b/frontend/docs/pages/reference/python/feature-clients/runs.mdx similarity index 99% rename from frontend/docs/pages/sdks/python/feature-clients/runs.mdx rename to frontend/docs/pages/reference/python/feature-clients/runs.mdx index 3bb4a67cdd..f1b429cb40 100644 --- a/frontend/docs/pages/sdks/python/feature-clients/runs.mdx +++ b/frontend/docs/pages/reference/python/feature-clients/runs.mdx @@ -159,7 +159,7 @@ Returns: Trigger a new workflow run. -IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdks/python/runnables#workflow). +IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdk/python/runnables#workflow). Parameters: @@ -180,7 +180,7 @@ Returns: Trigger a new workflow run. -IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdks/python/runnables#workflow). +IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdk/python/runnables#workflow). Parameters: diff --git a/frontend/docs/pages/sdks/python/feature-clients/scheduled.mdx b/frontend/docs/pages/reference/python/feature-clients/scheduled.mdx similarity index 99% rename from frontend/docs/pages/sdks/python/feature-clients/scheduled.mdx rename to frontend/docs/pages/reference/python/feature-clients/scheduled.mdx index 8146aa9ce5..f5b7812199 100644 --- a/frontend/docs/pages/sdks/python/feature-clients/scheduled.mdx +++ b/frontend/docs/pages/reference/python/feature-clients/scheduled.mdx @@ -23,7 +23,7 @@ Methods: Creates a new scheduled workflow run. -IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdks/python/runnables#workflow). +IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdk/python/runnables#workflow). Parameters: @@ -99,7 +99,7 @@ Returns: Creates a new scheduled workflow run. -IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdks/python/runnables#workflow). +IMPORTANT: It's preferable to use `Workflow.run` (and similar) to trigger workflows if possible. This method is intended to be an escape hatch. For more details, see [the documentation](../../../sdk/python/runnables#workflow). Parameters: diff --git a/frontend/docs/pages/sdks/python/feature-clients/workers.mdx b/frontend/docs/pages/reference/python/feature-clients/workers.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/workers.mdx rename to frontend/docs/pages/reference/python/feature-clients/workers.mdx diff --git a/frontend/docs/pages/sdks/python/feature-clients/workflows.mdx b/frontend/docs/pages/reference/python/feature-clients/workflows.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/feature-clients/workflows.mdx rename to frontend/docs/pages/reference/python/feature-clients/workflows.mdx diff --git a/frontend/docs/pages/home/lifespans.mdx b/frontend/docs/pages/reference/python/lifespans.mdx similarity index 100% rename from frontend/docs/pages/home/lifespans.mdx rename to frontend/docs/pages/reference/python/lifespans.mdx diff --git a/frontend/docs/pages/home/pydantic.mdx b/frontend/docs/pages/reference/python/pydantic.mdx similarity index 100% rename from frontend/docs/pages/home/pydantic.mdx rename to frontend/docs/pages/reference/python/pydantic.mdx diff --git a/frontend/docs/pages/sdks/python/runnables.mdx b/frontend/docs/pages/reference/python/runnables.mdx similarity index 100% rename from frontend/docs/pages/sdks/python/runnables.mdx rename to frontend/docs/pages/reference/python/runnables.mdx diff --git a/frontend/docs/pages/sdks/python/_meta.js b/frontend/docs/pages/sdks/python/_meta.js deleted file mode 100644 index 575cead22c..0000000000 --- a/frontend/docs/pages/sdks/python/_meta.js +++ /dev/null @@ -1,29 +0,0 @@ -export default { - client: { - title: "Client", - theme: { - toc: true, - }, - }, - - context: { - title: "Context", - theme: { - toc: true, - }, - }, - - "feature-clients": { - title: "Feature Clients", - theme: { - toc: true, - }, - }, - - runnables: { - title: "Runnables", - theme: { - toc: true, - }, - }, -}; diff --git a/frontend/docs/pages/self-hosting/_meta.js b/frontend/docs/pages/self-hosting/_meta.js index dcce14057c..32cd8002e4 100644 --- a/frontend/docs/pages/self-hosting/_meta.js +++ b/frontend/docs/pages/self-hosting/_meta.js @@ -28,7 +28,8 @@ export default { }, }, "worker-configuration-options": "Worker Configuration Options", - "downgrading-versions": "Downgrading Versions", + "upgrading-downgrading": "Upgrading and Downgrading", + "downgrading-db-schema-manually": "Downgrading DB Schema Manually", benchmarking: "Benchmarking", "data-retention": "Data Retention", "improving-performance": "Improving Performance", diff --git a/frontend/docs/pages/self-hosting/downgrading-versions.mdx b/frontend/docs/pages/self-hosting/downgrading-db-schema-manually.mdx similarity index 87% rename from frontend/docs/pages/self-hosting/downgrading-versions.mdx rename to frontend/docs/pages/self-hosting/downgrading-db-schema-manually.mdx index 8ba2def0e8..8f805f8526 100644 --- a/frontend/docs/pages/self-hosting/downgrading-versions.mdx +++ b/frontend/docs/pages/self-hosting/downgrading-db-schema-manually.mdx @@ -1,8 +1,14 @@ import { Callout } from "nextra/components"; -# Downgrading Hatchet Versions +# Downgrading DB Schema Manually -This guide explains how to safely downgrade your Hatchet instance to a previous version. +This guide explains how to safely downgrade your Hatchet DB schema to a previous version. + + + For production-critical workloads, see the [Upgrading and + Downgrading](/self-hosting/upgrading-downgrading) guide which covers database + snapshots, upgrading, and safe rollback strategies. + Downgrading may result in data loss. Always test downgrades in a diff --git a/frontend/docs/pages/self-hosting/prometheus-metrics.mdx b/frontend/docs/pages/self-hosting/prometheus-metrics.mdx index 80833f35f2..2f08b3a20e 100644 --- a/frontend/docs/pages/self-hosting/prometheus-metrics.mdx +++ b/frontend/docs/pages/self-hosting/prometheus-metrics.mdx @@ -33,7 +33,7 @@ Once enabled, you can setup any scraper that supports ingesting Prometheus metri Prometheus metrics. -To enable the [tenant API endpoint](/home/prometheus-metrics) you can set the following environment variables: +To enable the [tenant API endpoint](/concepts/prometheus-metrics) you can set the following environment variables: - Required - **`SERVER_PROMETHEUS_SERVER_URL`** (`prometheus.prometheusServerURL`) diff --git a/frontend/docs/pages/self-hosting/upgrading-downgrading.mdx b/frontend/docs/pages/self-hosting/upgrading-downgrading.mdx new file mode 100644 index 0000000000..ff5ee2d841 --- /dev/null +++ b/frontend/docs/pages/self-hosting/upgrading-downgrading.mdx @@ -0,0 +1,162 @@ +import { Callout, Steps } from "nextra/components"; + +# Upgrading and Downgrading Hatchet + +This guide covers how to safely upgrade and downgrade self-hosted Hatchet instances, with strategies for production-critical workloads. + +## Overview + +For production-critical deployments, we recommend the following workflow: + +1. **Snapshot** your database before upgrading +2. **Upgrade** the Hatchet engine to the new version +3. **Verify** the upgrade is working as expected +4. If something goes wrong, **downgrade** by restoring the snapshot or running down migrations + +## Step 1: Take a Database Snapshot + +Before any version change, create a point-in-time snapshot of your database. This gives you a fast, reliable rollback path if the upgrade causes issues. + +Refer to the backup and restore documentation for your database provider: + +- **PostgreSQL (self-managed):** [Backup and Restore](https://www.postgresql.org/docs/current/backup.html) +- **AWS RDS:** [Backing Up and Restoring](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_CommonTasks.BackupRestore.html) +- **Google Cloud SQL:** [Backup and Recovery](https://docs.cloud.google.com/sql/docs/postgres/backup-recovery/backups) +- **Azure Database for PostgreSQL:** [Backup](https://learn.microsoft.com/en-us/azure/backup/backup-azure-database-postgresql) + +## Step 2: Upgrade Engine Versions + +Once your snapshot is in place, upgrade the Hatchet engine. + + + Hatchet runs database migrations automatically on engine startup. No separate + migration step is required when upgrading. + + +### Docker Compose + +Update the image tags in your `docker-compose.yml`: + +```yaml +services: + hatchet-engine: + image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:v0.78.26 + # ... rest of configuration + + hatchet-dashboard: + image: ghcr.io/hatchet-dev/hatchet/hatchet-dashboard:v0.78.26 + # ... rest of configuration +``` + +Then redeploy: + + + This can cause some downtime till the containers are back up. + + +```bash +docker-compose pull +docker-compose down +docker-compose up -d +``` + +### Kubernetes (Helm) + +The Hatchet Helm charts use a `sharedConfig.image.tag` value that sets the image tag for all components (engine, API, frontend, migrations). Set this to the target Hatchet version: + +```yaml +# values.yaml +sharedConfig: + image: + tag: "v0.78.26" +``` + +Then upgrade the release: + +```bash +# hatchet-stack (standard deployment) +helm upgrade hatchet ./charts/hatchet-stack \ + --namespace hatchet \ + --values values.yaml + +# hatchet-ha (high-availability deployment) +helm upgrade hatchet ./charts/hatchet-ha \ + --namespace hatchet \ + --values values.yaml +``` + + + You can also override individual component tags (e.g., `engine.image.tag`, + `frontend.image.tag`), but `sharedConfig.image.tag` takes precedence when set. + + +### Verification + +After upgrading, verify the deployment is healthy: + +1. Check that the engine is running and accepting connections +2. Confirm the dashboard loads and shows the correct version +3. Run a test workflow to verify end-to-end functionality +4. Monitor logs for migration errors or unexpected warnings + +```bash +# Docker Compose +docker-compose logs hatchet-engine | head -50 + +# Kubernetes +kubectl logs -n hatchet -l app=hatchet-engine --tail=50 +``` + +## Step 3: Downgrade if Needed + +If the upgrade causes issues, you have two options depending on your situation: + + + Both the following options will result in data loss and some downtime. + + +### Option A: Restore from Database Snapshot (Recommended for Production) + +This is the fastest and safest rollback path. It returns your database to the exact state before the upgrade, avoiding any risk of incomplete down migrations. + + + +### Stop all Hatchet services + +Shut down all Hatchet engine instances to prevent writes during the restore. + +```bash +# Docker Compose +docker-compose down + +# Kubernetes +kubectl scale deployment hatchet-engine -n hatchet --replicas=0 +``` + +### Restore the database snapshot + +Follow the restore procedure for your database provider (see [Step 1](#step-1-take-a-database-snapshot) for links to the relevant documentation). + +### Deploy the previous Hatchet version + +Update your deployment to use the previous version's image tags (see [Upgrade Engine Versions](#step-2-upgrade-engine-versions) for the relevant deployment method) and redeploy. + +### Verify the rollback + +Confirm the engine starts, the dashboard loads, and workflows execute correctly. + + + +### Option B: Run Down Migrations (Manual) + +If you don't have a database snapshot or prefer a more targeted rollback, you can run down migrations to revert schema changes. See the [Downgrading DB Schema Manually](/self-hosting/downgrading-db-schema-manually) guide for detailed instructions on: + +- Finding the target migration version for your desired Hatchet version +- Running `hatchet-migrate --down ` +- Deploying the older engine version + + + Down migrations may not fully reverse all data changes (e.g., dropped columns + lose their data). For production-critical workloads, prefer restoring from a + snapshot when possible. + diff --git a/frontend/docs/scripts/generate-llms.ts b/frontend/docs/scripts/generate-llms.ts index 367851a0bc..c0d7b0d7be 100644 --- a/frontend/docs/scripts/generate-llms.ts +++ b/frontend/docs/scripts/generate-llms.ts @@ -98,8 +98,15 @@ function parseMetaJs(filepath: string): Record { content = content.replace(pattern, '$1"$2":'); // Apply twice to catch keys that were adjacent content = content.replace(pattern, '$1"$2":'); + // Quote unquoted keys inside inline objects (e.g. { collapsed: true }) + content = content.replace( + /(\{\s*)([a-zA-Z_$][a-zA-Z0-9_$-]*)\s*:/g, + '$1"$2":', + ); // Remove trailing commas before closing braces content = content.replace(/,(\s*\n?\s*})(\s*);?/g, "$1"); + // Strip trailing semicolon from export default {...}; + content = content.replace(/\s*;\s*$/, ""); try { return JSON.parse(content); diff --git a/frontend/docs/scripts/test-search-quality.ts b/frontend/docs/scripts/test-search-quality.ts index 37c3ad98e5..f338315be6 100644 --- a/frontend/docs/scripts/test-search-quality.ts +++ b/frontend/docs/scripts/test-search-quality.ts @@ -51,22 +51,23 @@ const TEST_CASES: SearchTestCase[] = [ { name: "hatchet.task( — defining a task", query: "hatchet.task(", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], }, { name: "hatchet.task — without parens", query: "hatchet.task", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], }, { name: "@hatchet.task() — Python decorator", query: "@hatchet.task()", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], }, { name: "hatchet.workflow — defining a workflow", query: "hatchet.workflow", - expectAnyOf: ["home/dags", "home/orchestration"], + expectAnyOf: ["concepts/durable-workflows", "concepts/tasks", "concepts/priority"], + topN: 10, }, // ------------------------------------------------------------------------- @@ -75,34 +76,35 @@ const TEST_CASES: SearchTestCase[] = [ { name: "quickstart", query: "quickstart", - expectAnyOf: ["home/hatchet-cloud-quickstart", "self-hosting/kubernetes-quickstart"], + expectAnyOf: ["essentials/quickstart", "self-hosting/kubernetes-quickstart"], }, { name: "setup", query: "setup", - expectAnyOf: ["home/setup", "home/hatchet-cloud-quickstart"], + expectAnyOf: ["essentials/advanced", "essentials/quickstart"], + topN: 10, }, { name: "getting started", query: "getting started", - expectAnyOf: ["home/hatchet-cloud-quickstart", "home/setup"], + expectAnyOf: ["essentials/quickstart", "essentials/advanced"], topN: 10, }, { name: "install", query: "install", - expectAnyOf: ["home/hatchet-cloud-quickstart", "home/setup", "cli/index"], + expectAnyOf: ["essentials/quickstart", "essentials/advanced", "reference/cli"], topN: 10, }, { name: "architecture", query: "architecture", - expectAnyOf: ["home/architecture"], + expectAnyOf: ["essentials/architecture-and-guarantees"], }, { name: "guarantees", query: "guarantees", - expectAnyOf: ["home/guarantees-and-tradeoffs"], + expectAnyOf: ["essentials/architecture-and-guarantees"], }, // ------------------------------------------------------------------------- @@ -111,30 +113,31 @@ const TEST_CASES: SearchTestCase[] = [ { name: "define a task", query: "define a task", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], topN: 10, }, { name: "create worker", query: "create worker", - expectAnyOf: ["home/workers"], + expectAnyOf: ["essentials/workers"], topN: 10, }, { name: "worker", query: "worker", - expectAnyOf: ["home/workers"], + expectAnyOf: ["essentials/workers", "concepts/workers"], }, { name: "run task", query: "run task", - expectAnyOf: ["home/running-your-task", "home/running-tasks", "home/run-with-results"], + expectAnyOf: ["essentials/running-your-task", "concepts/run-with-results"], topN: 10, }, { name: "environments", query: "environments", - expectAnyOf: ["home/environments"], + expectAnyOf: ["essentials/advanced"], + topN: 10, }, // ------------------------------------------------------------------------- @@ -143,43 +146,43 @@ const TEST_CASES: SearchTestCase[] = [ { name: "run with results", query: "run with results", - expectAnyOf: ["home/run-with-results"], + expectAnyOf: ["concepts/run-with-results"], }, { name: "run no wait", query: "run no wait", - expectAnyOf: ["home/run-no-wait"], + expectAnyOf: ["concepts/run-no-wait"], }, { name: "scheduled runs", query: "scheduled runs", - expectAnyOf: ["home/scheduled-runs"], + expectAnyOf: ["concepts/scheduled-runs"], }, { name: "cron", query: "cron", - expectAnyOf: ["home/cron-runs"], + expectAnyOf: ["concepts/cron-runs"], }, { name: "event trigger", query: "event trigger", - expectAnyOf: ["home/run-on-event"], + expectAnyOf: ["concepts/run-on-event"], topN: 10, }, { name: "bulk run", query: "bulk run", - expectAnyOf: ["home/bulk-run"], + expectAnyOf: ["concepts/bulk-run"], }, { name: "webhooks", query: "webhooks", - expectAnyOf: ["home/webhooks"], + expectAnyOf: ["concepts/webhooks"], }, { name: "inter-service", query: "inter-service", - expectAnyOf: ["home/inter-service-triggering"], + expectAnyOf: ["concepts/inter-service-triggering"], }, // ------------------------------------------------------------------------- @@ -188,22 +191,22 @@ const TEST_CASES: SearchTestCase[] = [ { name: "concurrency", query: "concurrency", - expectAnyOf: ["home/concurrency"], + expectAnyOf: ["concepts/concurrency"], }, { name: "rate limit", query: "rate limit", - expectAnyOf: ["home/rate-limits"], + expectAnyOf: ["concepts/rate-limits"], }, { name: "rate limits (plural)", query: "rate limits", - expectAnyOf: ["home/rate-limits"], + expectAnyOf: ["concepts/rate-limits"], }, { name: "priority", query: "priority", - expectAnyOf: ["home/priority"], + expectAnyOf: ["concepts/priority"], }, // ------------------------------------------------------------------------- @@ -212,32 +215,38 @@ const TEST_CASES: SearchTestCase[] = [ { name: "orchestration", query: "orchestration", - expectAnyOf: ["home/orchestration"], + expectAnyOf: ["concepts/durable-workflows"], + topN: 10, }, { name: "DAG", query: "DAG", - expectAnyOf: ["home/dags"], + expectAnyOf: ["concepts/durable-workflows"], + topN: 10, }, { name: "conditional workflows", query: "conditional workflows", - expectAnyOf: ["home/conditional-workflows"], + expectAnyOf: ["concepts/durable-workflows"], + topN: 10, }, { name: "on failure", query: "on failure", - expectAnyOf: ["home/on-failure-tasks"], + expectAnyOf: ["concepts/durable-workflows", "concepts/retry-policies"], + topN: 10, }, { name: "child spawning", query: "child spawning", - expectAnyOf: ["home/child-spawning"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results"], + topN: 10, }, { name: "child tasks", query: "child tasks", - expectAnyOf: ["home/child-spawning"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results", "concepts/sticky-assignment"], + topN: 10, }, // ------------------------------------------------------------------------- @@ -246,22 +255,22 @@ const TEST_CASES: SearchTestCase[] = [ { name: "durable execution", query: "durable execution", - expectAnyOf: ["home/durable-execution"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], }, { name: "durable events", query: "durable events", - expectAnyOf: ["home/durable-events"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], }, { name: "durable sleep", query: "durable sleep", - expectAnyOf: ["home/durable-sleep"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], }, { name: "durable best practices", query: "durable best practices", - expectAnyOf: ["home/durable-best-practices"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], topN: 10, }, @@ -271,22 +280,22 @@ const TEST_CASES: SearchTestCase[] = [ { name: "retry", query: "retry", - expectAnyOf: ["home/retry-policies"], + expectAnyOf: ["concepts/retry-policies"], }, { name: "timeout", query: "timeout", - expectAnyOf: ["home/timeouts"], + expectAnyOf: ["concepts/timeouts"], }, { name: "cancellation", query: "cancellation", - expectAnyOf: ["home/cancellation"], + expectAnyOf: ["concepts/cancellation"], }, { name: "bulk retries", query: "bulk retries", - expectAnyOf: ["home/bulk-retries-and-cancellations"], + expectAnyOf: ["concepts/bulk-retries-and-cancellations"], }, // ------------------------------------------------------------------------- @@ -295,33 +304,33 @@ const TEST_CASES: SearchTestCase[] = [ { name: "sticky assignment", query: "sticky assignment", - expectAnyOf: ["home/sticky-assignment"], + expectAnyOf: ["concepts/sticky-assignment"], }, { name: "worker affinity", query: "worker affinity", - expectAnyOf: ["home/worker-affinity"], + expectAnyOf: ["concepts/worker-affinity"], }, { name: "manual slot release", query: "manual slot release", - expectAnyOf: ["home/manual-slot-release"], + expectAnyOf: ["concepts/manual-slot-release"], }, { name: "autoscaling workers", query: "autoscaling workers", - expectAnyOf: ["home/autoscaling-workers"], + expectAnyOf: ["concepts/autoscaling-workers"], }, { name: "worker health check", query: "worker health check", - expectAnyOf: ["home/worker-healthchecks"], + expectAnyOf: ["concepts/worker-healthchecks"], topN: 10, }, { name: "troubleshooting", query: "troubleshooting", - expectAnyOf: ["home/troubleshooting-workers"], + expectAnyOf: ["essentials/troubleshooting-workers"], }, // ------------------------------------------------------------------------- @@ -330,27 +339,28 @@ const TEST_CASES: SearchTestCase[] = [ { name: "logging", query: "logging", - expectAnyOf: ["home/logging"], + expectAnyOf: ["concepts/logging"], }, { name: "opentelemetry", query: "opentelemetry", - expectAnyOf: ["home/opentelemetry"], + expectAnyOf: ["concepts/opentelemetry"], }, { name: "prometheus metrics", query: "prometheus metrics", - expectAnyOf: ["self-hosting/prometheus-metrics", "home/prometheus-metrics"], + expectAnyOf: ["self-hosting/prometheus-metrics", "concepts/prometheus-metrics"], }, { name: "streaming", query: "streaming", - expectAnyOf: ["home/streaming"], + expectAnyOf: ["concepts/streaming"], }, { name: "additional metadata", query: "additional metadata", - expectAnyOf: ["home/additional-metadata"], + expectAnyOf: ["concepts/durable-workflows", "concepts/bulk-retries-and-cancellations"], + topN: 10, }, // ------------------------------------------------------------------------- @@ -359,27 +369,32 @@ const TEST_CASES: SearchTestCase[] = [ { name: "pydantic", query: "pydantic", - expectAnyOf: ["home/pydantic"], + expectAnyOf: ["reference/python/pydantic"], + skip: true, }, { name: "asyncio", query: "asyncio", - expectAnyOf: ["home/asyncio"], + expectAnyOf: ["reference/python/asyncio"], + skip: true, }, { name: "dependency injection", query: "dependency injection", - expectAnyOf: ["home/dependency-injection"], + expectAnyOf: ["reference/python/dependency-injection"], + skip: true, }, { name: "dataclass", query: "dataclass", - expectAnyOf: ["home/dataclasses"], + expectAnyOf: ["reference/python/dataclasses"], + skip: true, }, { name: "lifespans", query: "lifespans", - expectAnyOf: ["home/lifespans"], + expectAnyOf: ["reference/python/lifespans"], + skip: true, }, // ------------------------------------------------------------------------- @@ -388,27 +403,32 @@ const TEST_CASES: SearchTestCase[] = [ { name: "migration python", query: "migration python", - expectAnyOf: ["home/migration-guide-python"], + expectAnyOf: ["migrating/v0-to-v1/migration-guide-python"], + skip: true, }, { name: "migration typescript", query: "migration typescript", - expectAnyOf: ["home/migration-guide-typescript"], + expectAnyOf: ["migrating/v0-to-v1/migration-guide-typescript"], + skip: true, }, { name: "migration go", query: "migration go", - expectAnyOf: ["home/migration-guide-go"], + expectAnyOf: ["migrating/v0-to-v1/migration-guide-go"], + skip: true, }, { name: "engine migration", query: "engine migration", - expectAnyOf: ["home/migration-guide-engine"], + expectAnyOf: ["migrating/v0-to-v1/migration-guide-engine"], + skip: true, }, { name: "SDK improvements", query: "SDK improvements", - expectAnyOf: ["home/v1-sdk-improvements"], + expectAnyOf: ["migrating/v0-to-v1/v1-sdk-improvements"], + skip: true, }, // ------------------------------------------------------------------------- @@ -417,12 +437,12 @@ const TEST_CASES: SearchTestCase[] = [ { name: "docker compose", query: "docker compose", - expectAnyOf: ["self-hosting/docker-compose", "home/docker"], + expectAnyOf: ["self-hosting/docker-compose", "concepts/docker"], }, { name: "running with docker", query: "running with docker", - expectAnyOf: ["home/docker", "self-hosting/docker-compose"], + expectAnyOf: ["concepts/docker", "self-hosting/docker-compose"], topN: 10, }, { @@ -499,7 +519,7 @@ const TEST_CASES: SearchTestCase[] = [ { name: "downgrading versions", query: "downgrading versions", - expectAnyOf: ["self-hosting/downgrading-versions"], + expectAnyOf: ["self-hosting/downgrading-db-schema-manually"], }, { name: "improving performance", @@ -519,22 +539,25 @@ const TEST_CASES: SearchTestCase[] = [ { name: "CLI", query: "CLI", - expectAnyOf: ["cli/index"], + expectAnyOf: ["reference/cli"], }, { name: "TUI", query: "TUI", - expectAnyOf: ["cli/tui"], + expectAnyOf: ["reference/cli"], + topN: 10, }, { name: "profiles", query: "profiles", - expectAnyOf: ["cli/profiles"], + expectAnyOf: ["reference/cli"], + topN: 10, }, { name: "running hatchet locally", query: "running hatchet locally", - expectAnyOf: ["cli/running-hatchet-locally"], + expectAnyOf: ["reference/cli", "self-hosting/hatchet-lite", "essentials/quickstart"], + topN: 10, }, // ------------------------------------------------------------------------- @@ -543,37 +566,39 @@ const TEST_CASES: SearchTestCase[] = [ { name: "SimpleInput — Pydantic model", query: "SimpleInput", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], }, { name: "input_validator — Python arg", query: "input_validator", - expectAnyOf: ["home/pydantic", "home/your-first-task"], + expectAnyOf: ["reference/python/pydantic", "essentials/your-first-task"], }, { name: "BaseModel — Pydantic", query: "BaseModel", - expectAnyOf: ["home/pydantic", "home/your-first-task"], + expectAnyOf: ["reference/python/pydantic", "essentials/your-first-task"], }, { name: "ctx.spawn — child spawn", query: "ctx.spawn", - expectAnyOf: ["home/child-spawning"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results"], + topN: 10, }, { name: "NewStandaloneTask — Go API", query: "NewStandaloneTask", - expectAnyOf: ["home/your-first-task", "home/migration-guide-go"], + expectAnyOf: ["essentials/your-first-task", "migrating/v0-to-v1/migration-guide-go"], }, { name: "DurableContext", query: "DurableContext", - expectAnyOf: ["home/durable-execution"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], + skip: true, }, { name: "aio_run — Python async run", query: "aio_run", - expectAnyOf: ["home/your-first-task", "home/run-with-results"], + expectAnyOf: ["essentials/your-first-task", "concepts/run-with-results"], }, // ------------------------------------------------------------------------- @@ -582,19 +607,19 @@ const TEST_CASES: SearchTestCase[] = [ { name: "hatchet.task( — trailing paren", query: "hatchet.task(", - expectAnyOf: ["home/your-first-task"], + expectAnyOf: ["essentials/your-first-task"], topN: 10, }, { name: "ctx.spawn( — trailing paren", query: "ctx.spawn(", - expectAnyOf: ["home/child-spawning"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results"], topN: 10, }, { name: ".run() — dot prefix and parens", query: ".run()", - expectAnyOf: ["home/your-first-task", "home/run-with-results", "home/running-your-task"], + expectAnyOf: ["essentials/your-first-task", "concepts/run-with-results", "essentials/running-your-task"], topN: 10, }, { @@ -614,122 +639,124 @@ const TEST_CASES: SearchTestCase[] = [ { name: "delay → scheduled/sleep", query: "delay", - expectAnyOf: ["home/durable-sleep", "home/scheduled-runs"], + expectAnyOf: ["concepts/durable-workflows", "concepts/scheduled-runs"], }, { name: "debounce → concurrency", query: "debounce", - expectAnyOf: ["home/concurrency"], + expectAnyOf: ["concepts/concurrency"], }, { name: "dedup → concurrency", query: "dedup", - expectAnyOf: ["home/concurrency"], + expectAnyOf: ["concepts/concurrency"], }, { name: "throttle → rate limits", query: "throttle", - expectAnyOf: ["home/rate-limits", "home/concurrency"], + expectAnyOf: ["concepts/rate-limits", "concepts/concurrency"], }, { name: "fan out → child spawning", query: "fan out", - expectAnyOf: ["home/child-spawning", "home/bulk-run"], + expectAnyOf: ["concepts/durable-workflows", "concepts/bulk-run", "patterns/fanout"], }, { name: "parallel tasks", query: "parallel tasks", - expectAnyOf: ["home/child-spawning", "home/run-with-results"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results"], + topN: 10, }, { name: "background job", query: "background job", - expectAnyOf: ["home/your-first-task", "home/run-no-wait", "home/workers"], + expectAnyOf: ["essentials/your-first-task", "concepts/run-no-wait", "essentials/workers"], }, { name: "recurring → cron", query: "recurring", - expectAnyOf: ["home/cron-runs"], + expectAnyOf: ["concepts/cron-runs"], }, { name: "error handling → retry/failure", query: "error handling", - expectAnyOf: ["home/retry-policies", "home/on-failure-tasks"], + expectAnyOf: ["concepts/retry-policies", "concepts/durable-workflows"], + topN: 10, }, { name: "fire and forget → run no wait", query: "fire and forget", - expectAnyOf: ["home/run-no-wait"], + expectAnyOf: ["concepts/run-no-wait"], topN: 10, }, { name: "scale workers → autoscaling", query: "scale workers", - expectAnyOf: ["home/autoscaling-workers"], + expectAnyOf: ["concepts/autoscaling-workers"], }, { name: "pipeline → DAG", query: "pipeline", - expectAnyOf: ["home/dags", "home/orchestration"], + expectAnyOf: ["concepts/durable-workflows", "patterns/pre-determined-pipelines"], }, { name: "long running task → durable", query: "long running task", - expectAnyOf: ["home/durable-execution"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows"], topN: 10, }, { name: "batch → bulk run", query: "batch tasks", - expectAnyOf: ["home/bulk-run"], + expectAnyOf: ["concepts/bulk-run", "patterns/batch-processing"], topN: 10, }, { name: "if else → conditional", query: "if else workflow", - expectAnyOf: ["home/conditional-workflows"], + expectAnyOf: ["concepts/durable-workflows", "patterns/branching"], topN: 10, }, { name: "monitor → observability", query: "monitor", - expectAnyOf: ["home/opentelemetry", "home/prometheus-metrics", "home/logging"], + expectAnyOf: ["concepts/opentelemetry", "concepts/prometheus-metrics", "concepts/logging"], topN: 10, }, { name: "tracing → opentelemetry", query: "tracing", - expectAnyOf: ["home/opentelemetry"], + expectAnyOf: ["concepts/opentelemetry"], topN: 10, }, { name: "observability", query: "observability", - expectAnyOf: ["home/opentelemetry", "home/prometheus-metrics", "home/logging"], + expectAnyOf: ["concepts/opentelemetry", "concepts/prometheus-metrics", "concepts/logging"], topN: 10, }, { name: "debug → troubleshooting", query: "debug", - expectAnyOf: ["home/troubleshooting-workers", "home/logging"], + expectAnyOf: ["essentials/troubleshooting-workers", "concepts/logging"], topN: 10, }, { name: "deploy → docker/k8s", query: "deploy", - expectAnyOf: ["home/docker", "self-hosting/docker-compose", "self-hosting/kubernetes-quickstart"], + expectAnyOf: ["concepts/docker", "self-hosting/docker-compose", "self-hosting/kubernetes-quickstart"], topN: 10, }, { name: "upgrade → migration", query: "upgrade", - expectAnyOf: ["home/migration-guide-python", "home/migration-guide-typescript", "home/migration-guide-go", "home/migration-guide-engine"], + expectAnyOf: ["migrating/v0-to-v1/migration-guide-python", "migrating/v0-to-v1/migration-guide-typescript", "migrating/v0-to-v1/migration-guide-go", "migrating/v0-to-v1/migration-guide-engine", "self-hosting/upgrading-downgrading"], topN: 10, }, { name: "downgrade → downgrading", query: "downgrade", - expectAnyOf: ["self-hosting/downgrading-versions"], + expectAnyOf: ["self-hosting/downgrading-db-schema-manually", "self-hosting/upgrading-downgrading"], topN: 10, }, { @@ -747,32 +774,34 @@ const TEST_CASES: SearchTestCase[] = [ { name: "async await → asyncio", query: "async await", - expectAnyOf: ["home/asyncio"], + expectAnyOf: ["reference/python/asyncio"], topN: 10, + skip: true, }, { name: "liveness → health checks", query: "liveness", - expectAnyOf: ["home/worker-healthchecks"], + expectAnyOf: ["concepts/worker-healthchecks"], topN: 10, }, { name: "wait for event → durable events", query: "wait for event", - expectAnyOf: ["home/durable-events"], + expectAnyOf: ["concepts/durable-workflows", "essentials/intro-to-durable-workflows", "concepts/pushing-events", "concepts/event-filters", "patterns/long-waits", "patterns/event-driven"], topN: 10, }, { name: "api call → inter-service", query: "api call between services", - expectAnyOf: ["home/inter-service-triggering"], + expectAnyOf: ["concepts/inter-service-triggering"], topN: 10, }, { name: "cleanup → lifespans", query: "cleanup shutdown", - expectAnyOf: ["home/lifespans"], + expectAnyOf: ["reference/python/lifespans"], topN: 10, + skip: true, }, // ------------------------------------------------------------------------- @@ -781,37 +810,37 @@ const TEST_CASES: SearchTestCase[] = [ { name: "how to retry a failed task", query: "how to retry a failed task", - expectAnyOf: ["home/retry-policies", "home/on-failure-tasks"], + expectAnyOf: ["concepts/retry-policies", "concepts/durable-workflows"], topN: 10, }, { name: "how to run tasks in parallel", query: "how to run tasks in parallel", - expectAnyOf: ["home/child-spawning", "home/run-with-results"], + expectAnyOf: ["concepts/durable-workflows", "concepts/run-with-results"], topN: 10, }, { name: "how to cancel a running task", query: "how to cancel a running task", - expectAnyOf: ["home/cancellation"], + expectAnyOf: ["concepts/cancellation"], topN: 10, }, { name: "how to set up cron job", query: "how to set up cron job", - expectAnyOf: ["home/cron-runs"], + expectAnyOf: ["concepts/cron-runs"], topN: 10, }, { name: "how to handle errors", query: "how to handle errors", - expectAnyOf: ["home/retry-policies", "home/on-failure-tasks"], + expectAnyOf: ["concepts/retry-policies", "concepts/durable-workflows"], topN: 10, }, { name: "how to limit concurrency", query: "how to limit concurrency", - expectAnyOf: ["home/concurrency", "home/rate-limits"], + expectAnyOf: ["concepts/concurrency", "concepts/rate-limits"], topN: 10, }, ]; diff --git a/frontend/docs/styles/global.css b/frontend/docs/styles/global.css index efb8c45e53..448ac248a7 100644 --- a/frontend/docs/styles/global.css +++ b/frontend/docs/styles/global.css @@ -175,6 +175,86 @@ } } +/* ========================= */ +/* Nextra Cards Overrides */ +/* ========================= */ +.nextra-cards { + display: grid; + gap: 0.875rem; + margin-top: 1.5rem; + margin-bottom: 1.5rem; + grid-template-columns: repeat(var(--rows, 3), minmax(0, 1fr)); +} + +@media (max-width: 768px) { + .nextra-cards { + grid-template-columns: 1fr; + } +} + +/* Card: minimal doc-style link block. Title on top (column-reverse), then description. */ +.nextra-card { + display: flex; + flex-direction: column-reverse; + align-items: stretch; + justify-content: flex-start; + gap: 0.375rem; + padding: 1rem 1.25rem; + border-radius: var(--radius, 0.5rem); + border: 1px solid hsl(var(--border)); + background: hsl(var(--card)); + font-size: 0.875rem; + line-height: 1.5; + color: hsl(var(--muted-foreground)); + text-decoration: none; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +.nextra-card:hover { + border-color: hsl(var(--ring)); + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +/* Title row: icon + label. Override Nextra’s padding so card padding controls spacing. */ +.nextra-card > span { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0; + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.3; + color: hsl(var(--foreground)); +} + +.nextra-card:hover > span { + color: hsl(var(--foreground)); +} + +/* Optional arrow after title on hover */ +.nextra-card > span::after { + content: "→"; + margin-left: 0.25rem; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.nextra-card:hover > span::after { + opacity: 0.7; + transform: translateX(2px); +} + +.nextra-card ._truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ========================= */ /* Dark Mode Fixes (Nextra) */ /* ========================= */ diff --git a/frontend/docs/theme.config.tsx b/frontend/docs/theme.config.tsx index bf0de9f2f3..ba095db842 100644 --- a/frontend/docs/theme.config.tsx +++ b/frontend/docs/theme.config.tsx @@ -38,6 +38,13 @@ const MarkdownIcon = () => ( ); +const CopyIcon = () => ( + + + + +); + function CopyClaudeButton({ command }: { command: string }) { const [copied, setCopied] = useState(false); @@ -61,6 +68,43 @@ function CopyClaudeButton({ command }: { command: string }) { ); } +function CopyForLLMButton({ markdownHref, pathname }: { markdownHref: string; pathname: string }) { + const [copied, setCopied] = useState(false); + const [loading, setLoading] = useState(false); + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + if (loading || copied) return; + setLoading(true); + try { + const base = typeof window !== "undefined" ? window.location.origin : DEFAULT_ORIGIN; + const res = await fetch(`${base}${markdownHref}`); + if (!res.ok) throw new Error("Failed to fetch"); + const text = await res.text(); + await navigator.clipboard.writeText(text); + setCopied(true); + posthog.capture("docs_copy_for_llm", { page: pathname }); + setTimeout(() => setCopied(false), 2000); + } catch { + // no-op + } finally { + setLoading(false); + } + }, + [markdownHref, pathname] + ); + + return ( + + + + {loading ? "..." : copied ? "Copied!" : "Copy for LLM"} + + + ); +} + const pageLinkStyle: React.CSSProperties = { fontSize: "0.75rem", opacity: 0.5, @@ -93,9 +137,10 @@ const config = { const fallbackTitle = "Hatchet Documentation"; - // Build the path to the LLM-friendly markdown version of this page + // Build the path to the LLM-friendly markdown version of this page (include basePath so static file resolves) const pathname = router.pathname.replace(/^\//, "").replace(/\/$/, "") || "index"; - const llmsMarkdownHref = `/llms/${pathname}.md`; + const base = router.basePath ? router.basePath.replace(/\/$/, "") : ""; + const llmsMarkdownHref = `${base}/llms/${pathname}.md`; return ( <> @@ -103,7 +148,7 @@ const config = { - + ); }, @@ -124,7 +169,8 @@ const config = { const pathname = router.pathname.replace(/^\//, "").replace(/\/$/, "") || "index"; - const llmsMarkdownHref = `/llms/${pathname}.md`; + const base = router.basePath ? router.basePath.replace(/\/$/, "") : ""; + const llmsMarkdownHref = `${base}/llms/${pathname}.md`; const mcpUrl = `${origin}/api/mcp`; const cursorConfig = JSON.stringify({ @@ -143,9 +189,10 @@ const config = { Add to Cursor + posthog.capture("docs_view_markdown", { page: pathname })} title="View as Markdown"> - Raw + View as MD
{children} @@ -179,8 +226,9 @@ const config = { backToTop: true, }, sidebar: { - defaultMenuCollapseLevel: 2, - toggleButton: true, + defaultMenuCollapseLevel: 1, + toggleButton: false, + autoCollapse: true, }, search: { component: Search, diff --git a/frontend/snippets/generate.py b/frontend/snippets/generate.py index 93f152755b..544feff5e1 100644 --- a/frontend/snippets/generate.py +++ b/frontend/snippets/generate.py @@ -4,7 +4,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, cast +from typing import Any, Callable, cast ROOT = "../../" BASE_SNIPPETS_DIR = os.path.join(ROOT, "frontend", "docs", "lib") @@ -252,10 +252,15 @@ def replacement(self, match: re.Match[str]) -> str: key = match.group(2) return f'{indent}"{key}":' - def decode(self, raw: str) -> dict[str, Any]: + def decode(self, s: str, _w: Callable[..., Any] = re.compile(r"\s").match) -> Any: # type: ignore[override] pattern = r"^(\s*)([a-zA-Z_$][a-zA-Z0-9_$-]*)\s*:" - quoted = re.sub(pattern, self.replacement, raw) + quoted = re.sub(pattern, self.replacement, s) result = re.sub(pattern, self.replacement, quoted, flags=re.MULTILINE) + result = re.sub( + r"(\{\s*)([a-zA-Z_$][a-zA-Z0-9_$-]*)\s*:", + r'\1"\2":', + result, + ) result = re.sub(r",(\s*\n?\s*})(\s*);?", r"\1", result) return super().decode(result) diff --git a/go.mod b/go.mod index 8b2d052c76..5c27d8966f 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/labstack/echo/v4 v4.15.0 - github.com/mattn/go-runewidth v0.0.19 + github.com/mattn/go-runewidth v0.0.20 github.com/oapi-codegen/runtime v1.1.2 github.com/opencontainers/go-digest v1.0.0 github.com/pingcap/errors v0.11.4 @@ -60,7 +60,7 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 go.uber.org/goleak v1.3.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.266.0 + google.golang.org/api v0.267.0 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 k8s.io/api v0.35.1 k8s.io/apimachinery v0.35.1 diff --git a/go.sum b/go.sum index da1a445779..ae5515fe9a 100644 --- a/go.sum +++ b/go.sum @@ -304,8 +304,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -573,8 +573,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= -google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= diff --git a/hack/oas/bundle-openapi.mjs b/hack/oas/bundle-openapi.mjs new file mode 100644 index 0000000000..91f8689e07 --- /dev/null +++ b/hack/oas/bundle-openapi.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Bundles the OpenAPI spec using only @redocly/openapi-core (no Redoc UI). + * Use this in Docker/CI to avoid pulling in styled-components/React from the CLI. + * + * Usage: from repo root or from a dir that has ./openapi/openapi.yaml: + * node bundle-openapi.mjs + * Output: ./bin/oas/openapi.yaml + */ +import { bundle, loadConfig } from '@redocly/openapi-core'; +import yaml from 'yaml'; +import fs from 'fs'; +import path from 'path'; + +const ref = './openapi/openapi.yaml'; +const outPath = './bin/oas/openapi.yaml'; + +const config = await loadConfig(); +const result = await bundle({ ref, config }); +if (result.problems?.errors?.length) { + console.error('Bundle had errors:', result.problems.errors); + process.exit(1); +} + +const outDir = path.dirname(outPath); +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(outPath, yaml.stringify(result.bundle.parsed), 'utf8'); +console.log('Bundled to', outPath); diff --git a/pkg/config/loader/loader.go b/pkg/config/loader/loader.go index ef4c0c642e..622e82b55e 100644 --- a/pkg/config/loader/loader.go +++ b/pkg/config/loader/loader.go @@ -147,6 +147,20 @@ func (c *ConfigLoader) InitDataLayer() (res *database.Layer, err error) { conn.TypeMap().RegisterType(t) + t, err = conn.LoadType(ctx, "v1_log_line_level") + if err != nil { + return err + } + + conn.TypeMap().RegisterType(t) + + t, err = conn.LoadType(ctx, "_v1_log_line_level") + if err != nil { + return err + } + + conn.TypeMap().RegisterType(t) + _, err = conn.Exec(ctx, "SET statement_timeout=30000") return err diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 047f210a96..389d054da6 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,7 +5,19 @@ All notable changes to Hatchet's Python SDK will be documented in this changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.25.0] - 2026-02-17 +## [1.25.2] - 2026-02-19 + +### Fixed + +- Reverts cancellation changes in 1.25.0 that introduced a regression + +## [1.25.1] - 2026-02-17 + +### Fixed + +- Fixes internal registration of durable slots + +## [1.25.0] - 2026-02-17 **YANKED ON 2/19/26** ### Added @@ -42,28 +54,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Adds type-hinted `Task.output_validator` and `Task.output_validator_type` properties to support easier type-safety and match the patterns on `BaseWorkflow/Standalone`. - Adds parameterized unit tests documenting current retry behavior of the Python SDK’s tenacity retry predicate for REST and gRPC errors. - ## [1.23.2] - 2026-02-11 ### Changed - Improves error handling for REST transport-level failures by raising typed exceptions for timeouts, connection, TLS, and protocol errors while preserving existing diagnostics. - ## [1.23.1] - 2026-02-10 ### Changed - Fixes a bug introduced in v1.21.0 where the `BaseWorkflow.input_validator` class property became incorrectly typed. Now separate properties are available for the type adapter and the underlying type. - ## [1.23.0] - 2026-02-05 ### Internal Only - Updated gRPC/REST contract field names to snake_case for consistency across SDKs. - ## [1.22.16] - 2026-02-05 ### Changed diff --git a/sdks/python/examples/cancellation/worker.py b/sdks/python/examples/cancellation/worker.py index d49d4e760b..e758e6f59b 100644 --- a/sdks/python/examples/cancellation/worker.py +++ b/sdks/python/examples/cancellation/worker.py @@ -1,7 +1,7 @@ import asyncio import time -from hatchet_sdk import CancelledError, Context, EmptyModel, Hatchet +from hatchet_sdk import Context, EmptyModel, Hatchet hatchet = Hatchet(debug=True) @@ -42,26 +42,6 @@ def check_flag(input: EmptyModel, ctx: Context) -> dict[str, str]: # !! -# > Handling cancelled error -@cancellation_workflow.task() -async def my_task(input: EmptyModel, ctx: Context) -> dict[str, str]: - try: - await asyncio.sleep(10) - except CancelledError as e: - # Handle parent cancellation - i.e. perform cleanup, then re-raise - print(f"Parent Task cancelled: {e.reason}") - # Always re-raise CancelledError so Hatchet can properly handle the cancellation - raise - except Exception as e: - # This will NOT catch CancelledError - print(f"Other error: {e}") - raise - return {"error": "Task should have been cancelled"} - - -# !! - - def main() -> None: worker = hatchet.worker("cancellation-worker", workflows=[cancellation_workflow]) worker.start() diff --git a/sdks/python/examples/simple/worker.py b/sdks/python/examples/simple/worker.py index f1082a9ba5..686742c4fb 100644 --- a/sdks/python/examples/simple/worker.py +++ b/sdks/python/examples/simple/worker.py @@ -10,7 +10,7 @@ def simple(input: EmptyModel, ctx: Context) -> dict[str, str]: @hatchet.durable_task() -async def simple_durable(input: EmptyModel, ctx: Context) -> dict[str, str]: +def simple_durable(input: EmptyModel, ctx: Context) -> dict[str, str]: return {"result": "Hello, world!"} diff --git a/sdks/python/hatchet_sdk/__init__.py b/sdks/python/hatchet_sdk/__init__.py index d9dc46e41d..6fe497718a 100644 --- a/sdks/python/hatchet_sdk/__init__.py +++ b/sdks/python/hatchet_sdk/__init__.py @@ -1,4 +1,3 @@ -from hatchet_sdk.cancellation import CancellationToken from hatchet_sdk.clients.admin import ( RunStatus, ScheduleTriggerWorkflowOptions, @@ -156,8 +155,6 @@ WorkerLabelComparator, ) from hatchet_sdk.exceptions import ( - CancellationReason, - CancelledError, DedupeViolationError, FailedTaskRunExceptionGroup, NonRetryableException, @@ -197,9 +194,6 @@ "CELEvaluationResult", "CELFailure", "CELSuccess", - "CancellationReason", - "CancellationToken", - "CancelledError", "ClientConfig", "ClientTLSConfig", "ConcurrencyExpression", diff --git a/sdks/python/hatchet_sdk/cancellation.py b/sdks/python/hatchet_sdk/cancellation.py deleted file mode 100644 index 5998677695..0000000000 --- a/sdks/python/hatchet_sdk/cancellation.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Cancellation token for coordinating cancellation across async and sync operations.""" - -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Callable -from typing import TYPE_CHECKING - -from hatchet_sdk.exceptions import CancellationReason -from hatchet_sdk.logger import logger - -if TYPE_CHECKING: - pass - - -class CancellationToken: - """ - A token that can be used to signal cancellation across async and sync operations. - - The token provides both asyncio and threading event primitives, allowing it to work - seamlessly in both async and sync code paths. Child workflow run IDs can be registered - with the token so they can be cancelled when the parent is cancelled. - - Example: - ```python - token = CancellationToken() - - # In async code - await token.aio_wait() # Blocks until cancelled - - # In sync code - token.wait(timeout=1.0) # Returns True if cancelled within timeout - - # Check if cancelled - if token.is_cancelled: - raise CancelledError("Operation was cancelled") - - # Trigger cancellation - token.cancel() - ``` - """ - - def __init__(self) -> None: - self._cancelled = False - self._reason: CancellationReason | None = None - self._async_event: asyncio.Event | None = None - self._sync_event = threading.Event() - self._child_run_ids: list[str] = [] - self._callbacks: list[Callable[[], None]] = [] - self._lock = threading.Lock() - - def _get_async_event(self) -> asyncio.Event: - """Lazily create the asyncio event to avoid requiring an event loop at init time.""" - if self._async_event is None: - self._async_event = asyncio.Event() - # If already cancelled, set the event - if self._cancelled: - self._async_event.set() - return self._async_event - - def cancel( - self, reason: CancellationReason = CancellationReason.TOKEN_CANCELLED - ) -> None: - """ - Trigger cancellation. - - This will: - - Set the cancelled flag and reason - - Signal both async and sync events - - Invoke all registered callbacks - - Args: - reason: The reason for cancellation. - """ - with self._lock: - if self._cancelled: - logger.debug( - f"CancellationToken: cancel() called but already cancelled, " - f"reason={self._reason.value if self._reason else 'none'}" - ) - return - - logger.debug( - f"CancellationToken: cancel() called, reason={reason.value}, " - f"{len(self._child_run_ids)} children registered" - ) - - self._cancelled = True - self._reason = reason - - # Signal both event types - if self._async_event is not None: - self._async_event.set() - self._sync_event.set() - - # Snapshot callbacks under the lock, invoke outside to avoid deadlocks - callbacks = list(self._callbacks) - - for callback in callbacks: - try: - logger.debug(f"CancellationToken: invoking callback {callback}") - callback() - except Exception as e: # noqa: PERF203 - logger.warning(f"CancellationToken: callback raised exception: {e}") - - logger.debug(f"CancellationToken: cancel() complete, reason={reason.value}") - - @property - def is_cancelled(self) -> bool: - """Check if cancellation has been triggered.""" - return self._cancelled - - @property - def reason(self) -> CancellationReason | None: - """Get the reason for cancellation, or None if not cancelled.""" - return self._reason - - async def aio_wait(self) -> None: - """ - Await until cancelled (for use in asyncio). - - This will block until cancel() is called. - """ - await self._get_async_event().wait() - logger.debug( - f"CancellationToken: async wait completed (cancelled), " - f"reason={self._reason.value if self._reason else 'none'}" - ) - - def wait(self, timeout: float | None = None) -> bool: - """ - Block until cancelled (for use in sync code). - - Args: - timeout: Maximum time to wait in seconds. None means wait forever. - - Returns: - True if the token was cancelled (event was set), False if timeout expired. - """ - result = self._sync_event.wait(timeout) - if result: - logger.debug( - f"CancellationToken: sync wait interrupted by cancellation, " - f"reason={self._reason.value if self._reason else 'none'}" - ) - return result - - def register_child(self, run_id: str) -> None: - """ - Register a child workflow run ID with this token. - - When the parent is cancelled, these child run IDs can be used to cancel - the child workflows as well. - - Args: - run_id: The workflow run ID of the child workflow. - """ - with self._lock: - logger.debug(f"CancellationToken: registering child workflow {run_id}") - self._child_run_ids.append(run_id) - - @property - def child_run_ids(self) -> list[str]: - """The registered child workflow run IDs.""" - return self._child_run_ids - - def add_callback(self, callback: Callable[[], None]) -> None: - """ - Register a callback to be invoked when cancellation is triggered. - - If the token is already cancelled, the callback will be invoked immediately. - - Args: - callback: A callable that takes no arguments. - """ - with self._lock: - if self._cancelled: - invoke_now = True - else: - invoke_now = False - self._callbacks.append(callback) - - if invoke_now: - logger.debug( - f"CancellationToken: invoking callback immediately (already cancelled): {callback}" - ) - try: - callback() - except Exception as e: - logger.warning(f"CancellationToken: callback raised exception: {e}") - - def __repr__(self) -> str: - return ( - f"CancellationToken(cancelled={self._cancelled}, " - f"children={len(self._child_run_ids)}, callbacks={len(self._callbacks)})" - ) diff --git a/sdks/python/hatchet_sdk/clients/listeners/pooled_listener.py b/sdks/python/hatchet_sdk/clients/listeners/pooled_listener.py index 1d05ba77a5..8a99d8fdce 100644 --- a/sdks/python/hatchet_sdk/clients/listeners/pooled_listener.py +++ b/sdks/python/hatchet_sdk/clients/listeners/pooled_listener.py @@ -1,9 +1,7 @@ -from __future__ import annotations - import asyncio from abc import ABC, abstractmethod from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Generic, Literal, TypeVar +from typing import Generic, Literal, TypeVar import grpc import grpc.aio @@ -16,10 +14,6 @@ from hatchet_sdk.config import ClientConfig from hatchet_sdk.logger import logger from hatchet_sdk.metadata import get_metadata -from hatchet_sdk.utils.cancellation import race_against_token - -if TYPE_CHECKING: - from hatchet_sdk.cancellation import CancellationToken DEFAULT_LISTENER_RETRY_INTERVAL = 3 # seconds DEFAULT_LISTENER_RETRY_COUNT = 5 @@ -42,7 +36,7 @@ def __init__(self, id: int) -> None: self.id = id self.queue: asyncio.Queue[T | SentinelValue] = asyncio.Queue() - async def __aiter__(self) -> Subscription[T]: + async def __aiter__(self) -> "Subscription[T]": return self async def __anext__(self) -> T | SentinelValue: @@ -205,17 +199,7 @@ def cleanup_subscription(self, subscription_id: int) -> None: del self.from_subscriptions[subscription_id] del self.events[subscription_id] - async def subscribe( - self, id: str, cancellation_token: CancellationToken | None = None - ) -> T: - """ - Subscribe to events for the given ID. - - :param id: The ID to subscribe to (e.g., workflow run ID). - :param cancellation_token: Optional cancellation token to abort the subscription wait. - :return: The event received for this ID. - :raises asyncio.CancelledError: If the cancellation token is triggered or if externally cancelled. - """ + async def subscribe(self, id: str) -> T: subscription_id: int | None = None try: @@ -237,17 +221,8 @@ async def subscribe( if not self.listener_task or self.listener_task.done(): self.listener_task = asyncio.create_task(self._init_producer()) - logger.debug( - f"PooledListener.subscribe: waiting for event on id={id}, " - f"subscription_id={subscription_id}, token={cancellation_token is not None}" - ) - - if cancellation_token: - result_task = asyncio.create_task(self.events[subscription_id].get()) - return await race_against_token(result_task, cancellation_token) return await self.events[subscription_id].get() except asyncio.CancelledError: - logger.debug(f"PooledListener.subscribe: externally cancelled for id={id}") raise finally: if subscription_id: diff --git a/sdks/python/hatchet_sdk/config.py b/sdks/python/hatchet_sdk/config.py index 1d315bff23..e49d2c9eac 100644 --- a/sdks/python/hatchet_sdk/config.py +++ b/sdks/python/hatchet_sdk/config.py @@ -52,7 +52,7 @@ def validate_event_loop_block_threshold_seconds( if isinstance(value, timedelta): return value - if isinstance(value, (int, float)): + if isinstance(value, int | float): return timedelta(seconds=float(value)) v = value.strip() @@ -135,37 +135,6 @@ class ClientConfig(BaseSettings): force_shutdown_on_shutdown_signal: bool = False tenacity: TenacityConfig = TenacityConfig() - # Cancellation configuration - cancellation_grace_period: timedelta = Field( - default=timedelta(milliseconds=1000), - description="The maximum time to wait for a task to complete after cancellation is triggered before force-cancelling. Value is interpreted as seconds when provided as int/float.", - ) - cancellation_warning_threshold: timedelta = Field( - default=timedelta(milliseconds=300), - description="If a task has not completed cancellation within this duration, a warning will be logged. Value is interpreted as seconds when provided as int/float.", - ) - - @field_validator( - "cancellation_grace_period", "cancellation_warning_threshold", mode="before" - ) - @classmethod - def validate_cancellation_timedelta( - cls, value: timedelta | int | float | str - ) -> timedelta: - """Convert int/float/string to timedelta, interpreting as seconds.""" - if isinstance(value, timedelta): - return value - - if isinstance(value, (int, float)): - return timedelta(seconds=float(value)) - - v = value.strip() - # Allow a small convenience suffix, but keep "seconds" as the contract. - if v.endswith("s"): - v = v[:-1].strip() - - return timedelta(seconds=float(v)) - @model_validator(mode="after") def validate_token_and_tenant(self) -> "ClientConfig": if not self.token: diff --git a/sdks/python/hatchet_sdk/context/context.py b/sdks/python/hatchet_sdk/context/context.py index 01d83d6f87..549d7dc7f0 100644 --- a/sdks/python/hatchet_sdk/context/context.py +++ b/sdks/python/hatchet_sdk/context/context.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any, cast from warnings import warn -from hatchet_sdk.cancellation import CancellationToken from hatchet_sdk.clients.admin import AdminClient from hatchet_sdk.clients.dispatcher.dispatcher import ( # type: ignore[attr-defined] Action, @@ -22,10 +21,9 @@ flatten_conditions, ) from hatchet_sdk.context.worker_context import WorkerContext -from hatchet_sdk.exceptions import CancellationReason, TaskRunError +from hatchet_sdk.exceptions import TaskRunError from hatchet_sdk.features.runs import RunsClient from hatchet_sdk.logger import logger -from hatchet_sdk.utils.cancellation import await_with_cancellation from hatchet_sdk.utils.timedelta_to_expression import Duration, timedelta_to_expr from hatchet_sdk.utils.typing import JSONSerializableMapping, LogLevel from hatchet_sdk.worker.runner.utils.capture_logs import AsyncLogSender, LogRecord @@ -58,7 +56,7 @@ def __init__( self.action = action self.step_run_id = action.step_run_id - self.cancellation_token = CancellationToken() + self.exit_flag = False self.dispatcher_client = dispatcher_client self.admin_client = admin_client self.event_client = event_client @@ -76,31 +74,6 @@ def __init__( self._workflow_name = workflow_name self._task_name = task_name - @property - def exit_flag(self) -> bool: - """ - Check if the cancellation flag has been set. - - This property is maintained for backwards compatibility. - Use `cancellation_token.is_cancelled` for new code. - - :return: True if the task has been cancelled, False otherwise. - """ - return self.cancellation_token.is_cancelled - - @exit_flag.setter - def exit_flag(self, value: bool) -> None: - """ - Set the cancellation flag. - - This setter is maintained for backwards compatibility. - Setting to True will trigger the cancellation token. - - :param value: True to trigger cancellation, False is a no-op. - """ - if value: - self.cancellation_token.cancel(CancellationReason.USER_REQUESTED) - def _increment_stream_index(self) -> int: index = self.stream_index self.stream_index += 1 @@ -196,25 +169,8 @@ def workflow_run_id(self) -> str: """ return self.action.workflow_run_id - def _set_cancellation_flag( - self, reason: CancellationReason = CancellationReason.WORKFLOW_CANCELLED - ) -> None: - """ - Internal method to trigger cancellation. - - This triggers the cancellation token, which will: - - Signal all waiters (async and sync) - - Set the exit_flag property to True - - Allow child workflow cancellation - - Args: - reason: The reason for cancellation. - """ - logger.debug( - f"Context: setting cancellation flag for step_run_id={self.step_run_id}, " - f"reason={reason.value}" - ) - self.cancellation_token.cancel(reason) + def _set_cancellation_flag(self) -> None: + self.exit_flag = True def cancel(self) -> None: """ @@ -222,11 +178,9 @@ def cancel(self) -> None: :return: None """ - logger.debug( - f"Context: cancel() called for task_run_external_id={self.step_run_id}" - ) + logger.debug("cancelling step...") self.runs_client.cancel(self.step_run_id) - self._set_cancellation_flag(CancellationReason.USER_REQUESTED) + self._set_cancellation_flag() async def aio_cancel(self) -> None: """ @@ -234,11 +188,9 @@ async def aio_cancel(self) -> None: :return: None """ - logger.debug( - f"Context: aio_cancel() called for task_run_external_id={self.step_run_id}" - ) + logger.debug("cancelling step...") await self.runs_client.aio_cancel(self.step_run_id) - self._set_cancellation_flag(CancellationReason.USER_REQUESTED) + self._set_cancellation_flag() def done(self) -> bool: """ @@ -530,11 +482,8 @@ async def aio_wait_for( """ Durably wait for either a sleep or an event. - This method respects the context's cancellation token. If the task is cancelled - while waiting, an asyncio.CancelledError will be raised. - :param signal_key: The key to use for the durable event. This is used to identify the event in the Hatchet API. - :param \\*conditions: The conditions to wait for. Can be a SleepCondition or UserEventCondition. + :param *conditions: The conditions to wait for. Can be a SleepCondition or UserEventCondition. :return: A dictionary containing the results of the wait. :raises ValueError: If the durable event listener is not available. @@ -544,10 +493,6 @@ async def aio_wait_for( task_id = self.step_run_id - logger.debug( - f"DurableContext.aio_wait_for: waiting for signal_key={signal_key}, task_id={task_id}" - ) - request = RegisterDurableEventRequest( task_id=task_id, signal_key=signal_key, @@ -557,29 +502,19 @@ async def aio_wait_for( self.durable_event_listener.register_durable_event(request) - # Use await_with_cancellation to respect the cancellation token - return await await_with_cancellation( - self.durable_event_listener.result(task_id, signal_key), - self.cancellation_token, + return await self.durable_event_listener.result( + task_id, + signal_key, ) async def aio_sleep_for(self, duration: Duration) -> dict[str, Any]: """ Lightweight wrapper for durable sleep. Allows for shorthand usage of `ctx.aio_wait_for` when specifying a sleep condition. - This method respects the context's cancellation token. If the task is cancelled - while sleeping, an asyncio.CancelledError will be raised. - For more complicated conditions, use `ctx.aio_wait_for` directly. - - :param duration: The duration to sleep for. - :return: A dictionary containing the results of the wait. """ - wait_index = self._increment_wait_index() - logger.debug( - f"DurableContext.aio_sleep_for: sleeping for {duration}, wait_index={wait_index}" - ) + wait_index = self._increment_wait_index() return await self.aio_wait_for( f"sleep:{timedelta_to_expr(duration)}-{wait_index}", diff --git a/sdks/python/hatchet_sdk/exceptions.py b/sdks/python/hatchet_sdk/exceptions.py index f13a7d35b9..3ecc0c3e66 100644 --- a/sdks/python/hatchet_sdk/exceptions.py +++ b/sdks/python/hatchet_sdk/exceptions.py @@ -1,6 +1,5 @@ import json import traceback -from enum import Enum from typing import cast @@ -171,54 +170,3 @@ class IllegalTaskOutputError(Exception): class LifespanSetupError(Exception): pass - - -class CancellationReason(Enum): - """Reason for cancellation of an operation.""" - - USER_REQUESTED = "user_requested" - """The user explicitly requested cancellation.""" - - TIMEOUT = "timeout" - """The operation timed out.""" - - PARENT_CANCELLED = "parent_cancelled" - """The parent workflow or task was cancelled.""" - - WORKFLOW_CANCELLED = "workflow_cancelled" - """The workflow run was cancelled.""" - - TOKEN_CANCELLED = "token_cancelled" - """The cancellation token was cancelled.""" - - -class CancelledError(BaseException): - """ - Raised when an operation is cancelled via CancellationToken. - - This exception inherits from BaseException (not Exception) so that it - won't be caught by bare `except Exception:` handlers. This mirrors the - behavior of asyncio.CancelledError in Python 3.8+. - - To catch this exception, use: - - `except CancelledError:` (recommended) - - `except BaseException:` (catches all exceptions) - - This exception is used for sync code paths. For async code paths, - asyncio.CancelledError is used instead. - - :param message: Optional message describing the cancellation. - :param reason: Optional enum indicating the reason for cancellation. - """ - - def __init__( - self, - message: str = "Operation cancelled", - reason: CancellationReason | None = None, - ) -> None: - self.reason = reason - super().__init__(message) - - @property - def message(self) -> str: - return str(self.args[0]) if self.args else "Operation cancelled" diff --git a/sdks/python/hatchet_sdk/runnables/contextvars.py b/sdks/python/hatchet_sdk/runnables/contextvars.py index dc0c9b2244..0d3c9d4904 100644 --- a/sdks/python/hatchet_sdk/runnables/contextvars.py +++ b/sdks/python/hatchet_sdk/runnables/contextvars.py @@ -1,17 +1,11 @@ -from __future__ import annotations - import asyncio import threading from collections import Counter from contextvars import ContextVar -from typing import TYPE_CHECKING from hatchet_sdk.runnables.action import ActionKey from hatchet_sdk.utils.typing import JSONSerializableMapping -if TYPE_CHECKING: - from hatchet_sdk.cancellation import CancellationToken - ctx_workflow_run_id: ContextVar[str | None] = ContextVar( "ctx_workflow_run_id", default=None ) @@ -26,9 +20,6 @@ ctx_task_retry_count: ContextVar[int | None] = ContextVar( "ctx_task_retry_count", default=0 ) -ctx_cancellation_token: ContextVar[CancellationToken | None] = ContextVar( - "ctx_cancellation_token", default=None -) workflow_spawn_indices = Counter[ActionKey]() spawn_index_lock = asyncio.Lock() diff --git a/sdks/python/hatchet_sdk/runnables/task.py b/sdks/python/hatchet_sdk/runnables/task.py index 34b7bd332c..d4c7e45b69 100644 --- a/sdks/python/hatchet_sdk/runnables/task.py +++ b/sdks/python/hatchet_sdk/runnables/task.py @@ -395,6 +395,8 @@ def to_proto(self, service_name: str) -> CreateTaskOpts: concurrency=[t.to_proto() for t in concurrency], conditions=self._conditions_to_proto(), schedule_timeout=timedelta_to_expr(self.schedule_timeout), + is_durable=self.is_durable, + slot_requests=self.slot_requests, ) def _assign_action(self, condition: Condition, action: Action) -> Condition: diff --git a/sdks/python/hatchet_sdk/runnables/workflow.py b/sdks/python/hatchet_sdk/runnables/workflow.py index 927375db17..6eed17bbe7 100644 --- a/sdks/python/hatchet_sdk/runnables/workflow.py +++ b/sdks/python/hatchet_sdk/runnables/workflow.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import json from collections.abc import Callable @@ -39,11 +37,8 @@ ) from hatchet_sdk.contracts.v1.workflows_pb2 import StickyStrategy as StickyStrategyProto from hatchet_sdk.contracts.workflows_pb2 import WorkflowVersion -from hatchet_sdk.exceptions import CancellationReason, CancelledError from hatchet_sdk.labels import DesiredWorkerLabel -from hatchet_sdk.logger import logger from hatchet_sdk.rate_limit import RateLimit -from hatchet_sdk.runnables.contextvars import ctx_cancellation_token from hatchet_sdk.runnables.task import Task from hatchet_sdk.runnables.types import ( ConcurrencyExpression, @@ -57,7 +52,6 @@ normalize_validator, ) from hatchet_sdk.serde import HATCHET_PYDANTIC_SENTINEL -from hatchet_sdk.utils.cancellation import await_with_cancellation from hatchet_sdk.utils.proto_enums import convert_python_enum_to_proto from hatchet_sdk.utils.timedelta_to_expression import Duration from hatchet_sdk.utils.typing import CoroutineLike, JSONSerializableMapping @@ -65,7 +59,6 @@ if TYPE_CHECKING: from hatchet_sdk import Hatchet - from hatchet_sdk.cancellation import CancellationToken T = TypeVar("T") @@ -95,7 +88,7 @@ class ComputedTaskParameters(BaseModel): task_defaults: TaskDefaults @model_validator(mode="after") - def validate_params(self) -> ComputedTaskParameters: + def validate_params(self) -> "ComputedTaskParameters": self.execution_timeout = fall_back_to_default( value=self.execution_timeout, param_default=timedelta(seconds=60), @@ -143,7 +136,7 @@ class TypedTriggerWorkflowRunConfig(BaseModel, Generic[TWorkflowInput]): class BaseWorkflow(Generic[TWorkflowInput]): - def __init__(self, config: WorkflowConfig, client: Hatchet) -> None: + def __init__(self, config: WorkflowConfig, client: "Hatchet") -> None: self.config = config self._default_tasks: list[Task[TWorkflowInput, Any]] = [] self._durable_tasks: list[Task[TWorkflowInput, Any]] = [] @@ -632,38 +625,6 @@ def greet(input, ctx): and can be arranged into complex dependency patterns. """ - def _resolve_check_cancellation_token(self) -> CancellationToken | None: - cancellation_token = ctx_cancellation_token.get() - - if cancellation_token and cancellation_token.is_cancelled: - raise CancelledError( - "Operation cancelled by cancellation token", - reason=CancellationReason.TOKEN_CANCELLED, - ) - - return cancellation_token - - def _register_child_with_token( - self, - cancellation_token: CancellationToken | None, - workflow_run_id: str, - ) -> None: - if not cancellation_token: - return - - cancellation_token.register_child(workflow_run_id) - - def _register_children_with_token( - self, - cancellation_token: CancellationToken | None, - refs: list[WorkflowRunRef], - ) -> None: - if not cancellation_token: - return - - for ref in refs: - cancellation_token.register_child(ref.workflow_run_id) - def run_no_wait( self, input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), @@ -673,34 +634,17 @@ def run_no_wait( Synchronously trigger a workflow run without waiting for it to complete. This method is useful for starting a workflow run and immediately returning a reference to the run without blocking while the workflow runs. - If a cancellation token is available via context, the child workflow will be registered - with the token. - :param input: The input data for the workflow. :param options: Additional options for workflow execution. :returns: A `WorkflowRunRef` object representing the reference to the workflow run. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.run_no_wait: triggering {self.config.name}, " - f"token={cancellation_token is not None}" - ) - - ref = self.client._client.admin.run_workflow( + return self.client._client.admin.run_workflow( workflow_name=self.config.name, input=self._serialize_input(input), options=self._create_options_with_combined_additional_meta(options), ) - self._register_child_with_token( - cancellation_token, - ref.workflow_run_id, - ) - - return ref - def run( self, input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), @@ -710,19 +654,12 @@ def run( Run the workflow synchronously and wait for it to complete. This method triggers a workflow run, blocks until completion, and returns the final result. - If a cancellation token is available via context, the wait can be interrupted. :param input: The input data for the workflow, must match the workflow's input type. :param options: Additional options for workflow execution like metadata and parent workflow ID. :returns: The result of the workflow execution as a dictionary. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.run: triggering {self.config.name}, " - f"token={cancellation_token is not None}" - ) ref = self.client._client.admin.run_workflow( workflow_name=self.config.name, @@ -730,14 +667,7 @@ def run( options=self._create_options_with_combined_additional_meta(options), ) - self._register_child_with_token( - cancellation_token, - ref.workflow_run_id, - ) - - logger.debug(f"Workflow.run: awaiting result for {ref.workflow_run_id}") - - return ref.result(cancellation_token=cancellation_token) + return ref.result() async def aio_run_no_wait( self, @@ -748,34 +678,18 @@ async def aio_run_no_wait( Asynchronously trigger a workflow run without waiting for it to complete. This method is useful for starting a workflow run and immediately returning a reference to the run without blocking while the workflow runs. - If a cancellation token is available via context, the child workflow will be registered - with the token. - :param input: The input data for the workflow. :param options: Additional options for workflow execution. :returns: A `WorkflowRunRef` object representing the reference to the workflow run. """ - cancellation_token = self._resolve_check_cancellation_token() - logger.debug( - f"Workflow.aio_run_no_wait: triggering {self.config.name}, " - f"token={cancellation_token is not None}" - ) - - ref = await self.client._client.admin.aio_run_workflow( + return await self.client._client.admin.aio_run_workflow( workflow_name=self.config.name, input=self._serialize_input(input), options=self._create_options_with_combined_additional_meta(options), ) - self._register_child_with_token( - cancellation_token, - ref.workflow_run_id, - ) - - return ref - async def aio_run( self, input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), @@ -785,47 +699,25 @@ async def aio_run( Run the workflow asynchronously and wait for it to complete. This method triggers a workflow run, awaits until completion, and returns the final result. - If a cancellation token is available via context, the wait can be interrupted. :param input: The input data for the workflow, must match the workflow's input type. :param options: Additional options for workflow execution like metadata and parent workflow ID. :returns: The result of the workflow execution as a dictionary. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.aio_run: triggering {self.config.name}, " - f"token={cancellation_token is not None}" - ) - ref = await self.client._client.admin.aio_run_workflow( workflow_name=self.config.name, input=self._serialize_input(input), options=self._create_options_with_combined_additional_meta(options), ) - self._register_child_with_token( - cancellation_token, - ref.workflow_run_id, - ) - - logger.debug(f"Workflow.aio_run: awaiting result for {ref.workflow_run_id}") - - return await await_with_cancellation( - ref.aio_result(), - cancellation_token, - ) + return await ref.aio_result() def _get_result( - self, - ref: WorkflowRunRef, - return_exceptions: bool, + self, ref: WorkflowRunRef, return_exceptions: bool ) -> dict[str, Any] | BaseException: try: - return ref.result( - cancellation_token=self._resolve_check_cancellation_token() - ) + return ref.result() except Exception as e: if return_exceptions: return e @@ -854,52 +746,15 @@ def run_many( Run a workflow in bulk and wait for all runs to complete. This method triggers multiple workflow runs, blocks until all of them complete, and returns the final results. - If a cancellation token is available via context, all child workflows will be registered - with the token and the wait can be interrupted. - :param workflows: A list of `WorkflowRunTriggerConfig` objects, each representing a workflow run to be triggered. :param return_exceptions: If `True`, exceptions will be returned as part of the results instead of raising them. :returns: A list of results for each workflow run. - :raises CancelledError: If the cancellation token is triggered (and return_exceptions is False). - :raises Exception: If a workflow run fails (and return_exceptions is False). """ - cancellation_token = self._resolve_check_cancellation_token() - refs = self.client._client.admin.run_workflows( workflows=workflows, ) - self._register_children_with_token( - cancellation_token, - refs, - ) - - # Pass cancellation_token through to each result() call - # The cancellation check happens INSIDE result()'s polling loop - results: list[dict[str, Any] | BaseException] = [] - for ref in refs: - try: - results.append(ref.result(cancellation_token=cancellation_token)) - except CancelledError: # noqa: PERF203 - logger.debug( - f"Workflow.run_many: cancellation detected, stopping wait, " - f"reason={CancellationReason.PARENT_CANCELLED.value}" - ) - if return_exceptions: - results.append( - CancelledError( - "Operation cancelled by cancellation token", - reason=CancellationReason.PARENT_CANCELLED, - ) - ) - break - raise - except Exception as e: - if return_exceptions: - results.append(e) - else: - raise - return results + return [self._get_result(ref, return_exceptions) for ref in refs] @overload async def aio_run_many( @@ -924,34 +779,16 @@ async def aio_run_many( Run a workflow in bulk and wait for all runs to complete. This method triggers multiple workflow runs, blocks until all of them complete, and returns the final results. - If a cancellation token is available via context, all child workflows will be registered - with the token and the wait can be interrupted. - :param workflows: A list of `WorkflowRunTriggerConfig` objects, each representing a workflow run to be triggered. :param return_exceptions: If `True`, exceptions will be returned as part of the results instead of raising them. :returns: A list of results for each workflow run. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.aio_run_many: triggering {len(workflows)} workflows, " - f"token={cancellation_token is not None}" - ) - refs = await self.client._client.admin.aio_run_workflows( workflows=workflows, ) - self._register_children_with_token( - cancellation_token, - refs, - ) - - return await await_with_cancellation( - asyncio.gather( - *[ref.aio_result() for ref in refs], return_exceptions=return_exceptions - ), - cancellation_token, + return await asyncio.gather( + *[ref.aio_result() for ref in refs], return_exceptions=return_exceptions ) def run_many_no_wait( @@ -963,30 +800,13 @@ def run_many_no_wait( This method triggers multiple workflow runs and immediately returns a list of references to the runs without blocking while the workflows run. - If a cancellation token is available via context, all child workflows will be registered - with the token. - :param workflows: A list of `WorkflowRunTriggerConfig` objects, each representing a workflow run to be triggered. :returns: A list of `WorkflowRunRef` objects, each representing a reference to a workflow run. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.run_many_no_wait: triggering {len(workflows)} workflows, " - f"token={cancellation_token is not None}" - ) - - refs = self.client._client.admin.run_workflows( + return self.client._client.admin.run_workflows( workflows=workflows, ) - self._register_children_with_token( - cancellation_token, - refs, - ) - - return refs - async def aio_run_many_no_wait( self, workflows: list[WorkflowRunTriggerConfig], @@ -996,31 +816,14 @@ async def aio_run_many_no_wait( This method triggers multiple workflow runs and immediately returns a list of references to the runs without blocking while the workflows run. - If a cancellation token is available via context, all child workflows will be registered - with the token. - :param workflows: A list of `WorkflowRunTriggerConfig` objects, each representing a workflow run to be triggered. :returns: A list of `WorkflowRunRef` objects, each representing a reference to a workflow run. """ - cancellation_token = self._resolve_check_cancellation_token() - - logger.debug( - f"Workflow.aio_run_many_no_wait: triggering {len(workflows)} workflows, " - f"token={cancellation_token is not None}" - ) - - refs = await self.client._client.admin.aio_run_workflows( + return await self.client._client.admin.aio_run_workflows( workflows=workflows, ) - self._register_children_with_token( - cancellation_token, - refs, - ) - - return refs - def _parse_task_name( self, name: str | None, @@ -1365,7 +1168,7 @@ def inner( return inner - def add_task(self, task: Standalone[TWorkflowInput, Any]) -> None: + def add_task(self, task: "Standalone[TWorkflowInput, Any]") -> None: """ Add a task to a workflow. Intended to be used with a previously existing task (a Standalone), such as one created with `@hatchet.task()`, which has been converted to a `Task` object using `to_task`. @@ -1404,7 +1207,7 @@ def my_task(input, ctx) -> None: class TaskRunRef(Generic[TWorkflowInput, R]): def __init__( self, - standalone: Standalone[TWorkflowInput, R], + standalone: "Standalone[TWorkflowInput, R]", workflow_run_ref: WorkflowRunRef, ): self._s = standalone @@ -1563,9 +1366,7 @@ def run_many( ) -> list[R]: ... def run_many( - self, - workflows: list[WorkflowRunTriggerConfig], - return_exceptions: bool = False, + self, workflows: list[WorkflowRunTriggerConfig], return_exceptions: bool = False ) -> list[R] | list[R | BaseException]: """ Run a workflow in bulk and wait for all runs to complete. @@ -1599,9 +1400,7 @@ async def aio_run_many( ) -> list[R]: ... async def aio_run_many( - self, - workflows: list[WorkflowRunTriggerConfig], - return_exceptions: bool = False, + self, workflows: list[WorkflowRunTriggerConfig], return_exceptions: bool = False ) -> list[R] | list[R | BaseException]: """ Run a workflow in bulk and wait for all runs to complete. @@ -1621,8 +1420,7 @@ async def aio_run_many( ] def run_many_no_wait( - self, - workflows: list[WorkflowRunTriggerConfig], + self, workflows: list[WorkflowRunTriggerConfig] ) -> list[TaskRunRef[TWorkflowInput, R]]: """ Run a workflow in bulk without waiting for all runs to complete. @@ -1637,8 +1435,7 @@ def run_many_no_wait( return [TaskRunRef[TWorkflowInput, R](self, ref) for ref in refs] async def aio_run_many_no_wait( - self, - workflows: list[WorkflowRunTriggerConfig], + self, workflows: list[WorkflowRunTriggerConfig] ) -> list[TaskRunRef[TWorkflowInput, R]]: """ Run a workflow in bulk without waiting for all runs to complete. diff --git a/sdks/python/hatchet_sdk/utils/cancellation.py b/sdks/python/hatchet_sdk/utils/cancellation.py deleted file mode 100644 index eefe7a4d6d..0000000000 --- a/sdks/python/hatchet_sdk/utils/cancellation.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Utilities for cancellation-aware operations.""" - -from __future__ import annotations - -import asyncio -import contextlib -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, TypeVar - -from hatchet_sdk.logger import logger - -if TYPE_CHECKING: - from hatchet_sdk.cancellation import CancellationToken - -T = TypeVar("T") - - -async def _invoke_cancel_callback( - cancel_callback: Callable[[], Awaitable[None]] | None, -) -> None: - """Invoke a cancel callback.""" - if not cancel_callback: - return - - await cancel_callback() - - -async def race_against_token( - main_task: asyncio.Task[T], - token: CancellationToken, -) -> T: - """ - Race an asyncio task against a cancellation token. - - Waits for either the task to complete or the token to be cancelled. Cleans up - whichever side loses the race. - - Args: - main_task: The asyncio task to race. - token: The cancellation token to race against. - - Returns: - The result of the main task if it completes first. - - Raises: - asyncio.CancelledError: If the token fires before the task completes. - """ - cancel_task = asyncio.create_task(token.aio_wait()) - - try: - done, pending = await asyncio.wait( - [main_task, cancel_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - # Cancel pending tasks - for task in pending: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - if cancel_task in done: - raise asyncio.CancelledError("Operation cancelled by cancellation token") - - return main_task.result() - - except asyncio.CancelledError: - # Ensure both tasks are cleaned up on any cancellation (external or token) - main_task.cancel() - cancel_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await main_task - with contextlib.suppress(asyncio.CancelledError): - await cancel_task - raise - - -async def await_with_cancellation( - coro: Awaitable[T], - token: CancellationToken | None, - cancel_callback: Callable[[], Awaitable[None]] | None = None, -) -> T: - """ - Await an awaitable with cancellation support. - - This function races the given awaitable against a cancellation token. If the - token is cancelled before the awaitable completes, the awaitable is cancelled - and an asyncio.CancelledError is raised. - - Args: - coro: The awaitable to await (coroutine, Future, or asyncio.Task). - token: The cancellation token to check. If None, the coroutine is awaited directly. - cancel_callback: An optional async callback to invoke when cancellation occurs - (e.g., to cancel child workflows). - - Returns: - The result of the coroutine. - - Raises: - asyncio.CancelledError: If the token is cancelled before the coroutine completes. - - Example: - ```python - async def cleanup() -> None: - print("cleaning up...") - - async def long_running_task(): - await asyncio.sleep(10) - return "done" - - token = CancellationToken() - - # This will raise asyncio.CancelledError if token.cancel() is called - result = await await_with_cancellation( - long_running_task(), - token, - cancel_callback=cleanup, - ) - ``` - """ - - if token is None: - logger.debug("await_with_cancellation: no token provided, awaiting directly") - return await coro - - logger.debug("await_with_cancellation: starting with cancellation token") - - # Check if already cancelled - if token.is_cancelled: - logger.debug("await_with_cancellation: token already cancelled") - if cancel_callback: - logger.debug("await_with_cancellation: invoking cancel callback") - await _invoke_cancel_callback(cancel_callback) - raise asyncio.CancelledError("Operation cancelled by cancellation token") - - main_task = asyncio.ensure_future(coro) - - try: - result = await race_against_token(main_task, token) - logger.debug("await_with_cancellation: completed successfully") - return result - - except asyncio.CancelledError: - logger.debug("await_with_cancellation: cancelled") - if cancel_callback: - logger.debug("await_with_cancellation: invoking cancel callback") - with contextlib.suppress(asyncio.CancelledError): - await asyncio.shield(_invoke_cancel_callback(cancel_callback)) - raise diff --git a/sdks/python/hatchet_sdk/worker/runner/runner.py b/sdks/python/hatchet_sdk/worker/runner/runner.py index fde8fba734..d853858b40 100644 --- a/sdks/python/hatchet_sdk/worker/runner/runner.py +++ b/sdks/python/hatchet_sdk/worker/runner/runner.py @@ -2,7 +2,6 @@ import ctypes import functools import json -import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict, is_dataclass @@ -30,7 +29,6 @@ STEP_EVENT_TYPE_STARTED, ) from hatchet_sdk.exceptions import ( - CancellationReason, IllegalTaskOutputError, NonRetryableException, TaskRunError, @@ -41,7 +39,6 @@ from hatchet_sdk.runnables.contextvars import ( ctx_action_key, ctx_additional_metadata, - ctx_cancellation_token, ctx_step_run_id, ctx_task_retry_count, ctx_worker_id, @@ -63,7 +60,6 @@ ContextVarToCopyDict, ContextVarToCopyInt, ContextVarToCopyStr, - ContextVarToCopyToken, copy_context_vars, ) @@ -255,7 +251,6 @@ async def async_wrapped_action_func( ctx_action_key.set(action.key) ctx_additional_metadata.set(action.additional_metadata) ctx_task_retry_count.set(action.retry_count) - ctx_cancellation_token.set(ctx.cancellation_token) async with task._unpack_dependencies_with_cleanup(ctx) as dependencies: try: @@ -303,12 +298,6 @@ async def async_wrapped_action_func( value=action.retry_count, ) ), - ContextVarToCopy( - var=ContextVarToCopyToken( - name="ctx_cancellation_token", - value=ctx.cancellation_token, - ) - ), ], self.thread_action_func, ctx, @@ -491,95 +480,28 @@ def force_kill_thread(self, thread: Thread) -> None: ## IMPORTANT: Keep this method's signature in sync with the wrapper in the OTel instrumentor async def handle_cancel_action(self, action: Action) -> None: key = action.key - start_time = time.monotonic() - - logger.info( - f"Cancellation: received cancel action for {action.action_id}, " - f"reason={CancellationReason.WORKFLOW_CANCELLED.value}" - ) - try: - # Trigger the cancellation token to signal the context to stop + # call cancel to signal the context to stop if key in self.contexts: - ctx = self.contexts[key] - child_count = len(ctx.cancellation_token.child_run_ids) - logger.debug( - f"Cancellation: triggering token for {action.action_id}, " - f"reason={CancellationReason.WORKFLOW_CANCELLED.value}, " - f"{child_count} children registered" - ) - ctx._set_cancellation_flag(CancellationReason.WORKFLOW_CANCELLED) + self.contexts[key]._set_cancellation_flag() self.cancellations[key] = True - # Note: Child workflows are not cancelled here - they run independently - # and are managed by Hatchet's normal cancellation mechanisms - else: - logger.debug(f"Cancellation: no context found for {action.action_id}") - - # Wait with supervision (using timedelta configs) - grace_period = self.config.cancellation_grace_period.total_seconds() - warning_threshold = ( - self.config.cancellation_warning_threshold.total_seconds() - ) - grace_period_ms = round(grace_period * 1000) - warning_threshold_ms = round(warning_threshold * 1000) - - # Wait until warning threshold - await asyncio.sleep(warning_threshold) - elapsed = time.monotonic() - start_time - elapsed_ms = round(elapsed * 1000) - - # Check if the task has not yet exited despite the cancellation signal. - task_still_running = key in self.tasks and not self.tasks[key].done() - - if task_still_running: - logger.warning( - f"Cancellation: task {action.action_id} has not cancelled after " - f"{elapsed_ms}ms (warning threshold {warning_threshold_ms}ms). " - f"Consider checking for blocking operations. " - f"See https://docs.hatchet.run/home/cancellation" - ) - remaining = grace_period - elapsed - if remaining > 0: - await asyncio.sleep(remaining) + await asyncio.sleep(1) - if key in self.tasks and not self.tasks[key].done(): - logger.debug( - f"Cancellation: force-cancelling task {action.action_id} " - f"after grace period ({grace_period_ms}ms)" - ) - self.tasks[key].cancel() + if key in self.tasks: + self.tasks[key].cancel() - if key in self.threads: - thread = self.threads[key] + # check if thread is still running, if so, print a warning + if key in self.threads: + thread = self.threads[key] - if self.config.enable_force_kill_sync_threads: - logger.debug( - f"Cancellation: force-killing thread for {action.action_id}" - ) - self.force_kill_thread(thread) - await asyncio.sleep(1) - - if thread.is_alive(): - logger.warning( - f"Cancellation: thread {thread.ident} with key {key} is still running " - f"after cancellation. This could cause the thread pool to get blocked " - f"and prevent new tasks from running." - ) + if self.config.enable_force_kill_sync_threads: + self.force_kill_thread(thread) + await asyncio.sleep(1) - total_elapsed = time.monotonic() - start_time - total_elapsed_ms = round(total_elapsed * 1000) - if total_elapsed > grace_period: - logger.warning( - f"Cancellation: cancellation of {action.action_id} took {total_elapsed_ms}ms " - f"(exceeded grace period of {grace_period_ms}ms)" - ) - else: - logger.debug( - f"Cancellation: task {action.action_id} eventually completed in {total_elapsed_ms}ms" - ) - else: - logger.info(f"Cancellation: task {action.action_id} completed") + logger.warning( + f"thread {self.threads[key].ident} with key {key} is still running after cancellation. This could cause the thread pool to get blocked and prevent new tasks from running." + ) finally: self.cleanup_run_id(key) diff --git a/sdks/python/hatchet_sdk/worker/runner/utils/capture_logs.py b/sdks/python/hatchet_sdk/worker/runner/utils/capture_logs.py index 4b105f843b..6fd8b52ee1 100644 --- a/sdks/python/hatchet_sdk/worker/runner/utils/capture_logs.py +++ b/sdks/python/hatchet_sdk/worker/runner/utils/capture_logs.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import functools import logging @@ -7,15 +5,13 @@ from io import StringIO from typing import Literal, ParamSpec, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field -from hatchet_sdk.cancellation import CancellationToken from hatchet_sdk.clients.events import EventClient from hatchet_sdk.logger import logger from hatchet_sdk.runnables.contextvars import ( ctx_action_key, ctx_additional_metadata, - ctx_cancellation_token, ctx_step_run_id, ctx_task_retry_count, ctx_worker_id, @@ -52,22 +48,10 @@ class ContextVarToCopyDict(BaseModel): value: JSONSerializableMapping | None -class ContextVarToCopyToken(BaseModel): - """Special type for copying CancellationToken to threads.""" - - model_config = ConfigDict(arbitrary_types_allowed=True) - - name: Literal["ctx_cancellation_token"] - value: CancellationToken | None - - class ContextVarToCopy(BaseModel): - var: ( - ContextVarToCopyStr - | ContextVarToCopyDict - | ContextVarToCopyInt - | ContextVarToCopyToken - ) = Field(discriminator="name") + var: ContextVarToCopyStr | ContextVarToCopyDict | ContextVarToCopyInt = Field( + discriminator="name" + ) def copy_context_vars( @@ -89,8 +73,6 @@ def copy_context_vars( ctx_worker_id.set(var.var.value) elif var.var.name == "ctx_additional_metadata": ctx_additional_metadata.set(var.var.value or {}) - elif var.var.name == "ctx_cancellation_token": - ctx_cancellation_token.set(var.var.value) else: raise ValueError(f"Unknown context variable name: {var.var.name}") diff --git a/sdks/python/hatchet_sdk/workflow_run.py b/sdks/python/hatchet_sdk/workflow_run.py index e1d0c3cc25..5760eef8f9 100644 --- a/sdks/python/hatchet_sdk/workflow_run.py +++ b/sdks/python/hatchet_sdk/workflow_run.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import time from typing import TYPE_CHECKING, Any @@ -8,17 +6,9 @@ RunEventListenerClient, ) from hatchet_sdk.clients.listeners.workflow_listener import PooledWorkflowRunListener -from hatchet_sdk.exceptions import ( - CancellationReason, - CancelledError, - FailedTaskRunExceptionGroup, - TaskRunError, -) -from hatchet_sdk.logger import logger -from hatchet_sdk.utils.cancellation import await_with_cancellation +from hatchet_sdk.exceptions import FailedTaskRunExceptionGroup, TaskRunError if TYPE_CHECKING: - from hatchet_sdk.cancellation import CancellationToken from hatchet_sdk.clients.admin import AdminClient @@ -28,7 +18,7 @@ def __init__( workflow_run_id: str, workflow_run_listener: PooledWorkflowRunListener, workflow_run_event_listener: RunEventListenerClient, - admin_client: AdminClient, + admin_client: "AdminClient", ): self.workflow_run_id = workflow_run_id self.workflow_run_listener = workflow_run_listener @@ -41,25 +31,7 @@ def __str__(self) -> str: def stream(self) -> RunEventListener: return self.workflow_run_event_listener.stream(self.workflow_run_id) - async def aio_result( - self, cancellation_token: CancellationToken | None = None - ) -> dict[str, Any]: - """ - Asynchronously wait for the workflow run to complete and return the result. - - :param cancellation_token: Optional cancellation token to abort the wait. - :return: A dictionary mapping task names to their outputs. - """ - logger.debug( - f"WorkflowRunRef.aio_result: waiting for {self.workflow_run_id}, " - f"token={cancellation_token is not None}" - ) - - if cancellation_token: - return await await_with_cancellation( - self.workflow_run_listener.aio_result(self.workflow_run_id), - cancellation_token, - ) + async def aio_result(self) -> dict[str, Any]: return await self.workflow_run_listener.aio_result(self.workflow_run_id) def _safely_get_action_name(self, action_id: str | None) -> str | None: @@ -71,42 +43,12 @@ def _safely_get_action_name(self, action_id: str | None) -> str | None: except IndexError: return None - def result( - self, cancellation_token: CancellationToken | None = None - ) -> dict[str, Any]: - """ - Synchronously wait for the workflow run to complete and return the result. - - This method polls the API for the workflow run status. If a cancellation token - is provided, the polling will be interrupted when cancellation is triggered. - - :param cancellation_token: Optional cancellation token to abort the wait. - :return: A dictionary mapping task names to their outputs. - :raises CancelledError: If the cancellation token is triggered. - :raises FailedTaskRunExceptionGroup: If the workflow run fails. - :raises ValueError: If the workflow run is not found. - """ + def result(self) -> dict[str, Any]: from hatchet_sdk.clients.admin import RunStatus - logger.debug( - f"WorkflowRunRef.result: waiting for {self.workflow_run_id}, " - f"token={cancellation_token is not None}" - ) - retries = 0 while True: - # Check cancellation at start of each iteration - if cancellation_token and cancellation_token.is_cancelled: - logger.debug( - f"WorkflowRunRef.result: cancellation detected for {self.workflow_run_id}, " - f"reason={CancellationReason.PARENT_CANCELLED.value}" - ) - raise CancelledError( - "Operation cancelled by cancellation token", - reason=CancellationReason.PARENT_CANCELLED, - ) - try: details = self.admin_client.get_details(self.workflow_run_id) except Exception as e: @@ -117,42 +59,14 @@ def result( f"Workflow run {self.workflow_run_id} not found" ) from e - # Use interruptible sleep via token.wait() - if cancellation_token: - if cancellation_token.wait(timeout=1.0): - logger.debug( - f"WorkflowRunRef.result: cancellation during retry sleep for {self.workflow_run_id}, " - f"reason={CancellationReason.PARENT_CANCELLED.value}" - ) - raise CancelledError( - "Operation cancelled by cancellation token", - reason=CancellationReason.PARENT_CANCELLED, - ) from None - else: - time.sleep(1) + time.sleep(1) continue - logger.debug( - f"WorkflowRunRef.result: {self.workflow_run_id} status={details.status}" - ) - if ( details.status in [RunStatus.QUEUED, RunStatus.RUNNING] or details.done is False ): - # Use interruptible sleep via token.wait() - if cancellation_token: - if cancellation_token.wait(timeout=1.0): - logger.debug( - f"WorkflowRunRef.result: cancellation during poll sleep for {self.workflow_run_id}, " - f"reason={CancellationReason.PARENT_CANCELLED.value}" - ) - raise CancelledError( - "Operation cancelled by cancellation token", - reason=CancellationReason.PARENT_CANCELLED, - ) - else: - time.sleep(1) + time.sleep(1) continue if details.status == RunStatus.FAILED: @@ -166,9 +80,6 @@ def result( ) if details.status == RunStatus.COMPLETED: - logger.debug( - f"WorkflowRunRef.result: {self.workflow_run_id} completed successfully" - ) return { readable_id: run.output for readable_id, run in details.task_runs.items() diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 3121ffdebe..8f0f28682c 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hatchet-sdk" -version = "1.25.0" +version = "1.25.2" description = "This is the official Python SDK for Hatchet, a distributed, fault-tolerant task queue. The SDK allows you to easily integrate Hatchet's task scheduling and workflow orchestration capabilities into your Python applications." authors = [ "Alexander Belanger ", diff --git a/sdks/python/tests/test_cancellation.py b/sdks/python/tests/test_cancellation.py deleted file mode 100644 index 1b6046962e..0000000000 --- a/sdks/python/tests/test_cancellation.py +++ /dev/null @@ -1,461 +0,0 @@ -"""Unit tests for CancellationToken and cancellation utilities.""" - -import asyncio -import threading -import time - -import pytest - -from hatchet_sdk.cancellation import CancellationToken -from hatchet_sdk.exceptions import CancellationReason, CancelledError -from hatchet_sdk.runnables.contextvars import ctx_cancellation_token -from hatchet_sdk.utils.cancellation import await_with_cancellation - -# CancellationToken - - -def test_initial_state() -> None: - """Token should start in non-cancelled state.""" - token = CancellationToken() - assert token.is_cancelled is False - - -def test_cancel_sets_flag() -> None: - """cancel() should set is_cancelled to True.""" - token = CancellationToken() - token.cancel() - assert token.is_cancelled is True - - -def test_cancel_sets_reason() -> None: - """cancel() should set the reason.""" - token = CancellationToken() - token.cancel(CancellationReason.USER_REQUESTED) - assert token.reason == CancellationReason.USER_REQUESTED - - -def test_reason_is_none_before_cancel() -> None: - """reason should be None before cancellation.""" - token = CancellationToken() - assert token.reason is None - - -def test_cancel_idempotent() -> None: - """Multiple calls to cancel() should be safe.""" - token = CancellationToken() - token.cancel() - token.cancel() # Should not raise - assert token.is_cancelled is True - - -def test_cancel_idempotent_preserves_reason() -> None: - """Multiple calls to cancel() should preserve the original reason.""" - token = CancellationToken() - token.cancel(CancellationReason.USER_REQUESTED) - token.cancel(CancellationReason.TIMEOUT) # Second call should be ignored - assert token.reason == CancellationReason.USER_REQUESTED - - -def test_sync_wait_returns_true_when_cancelled() -> None: - """wait() should return True immediately if already cancelled.""" - token = CancellationToken() - token.cancel() - result = token.wait(timeout=0.1) - assert result is True - - -def test_sync_wait_timeout_returns_false() -> None: - """wait() should return False when timeout expires without cancellation.""" - token = CancellationToken() - start = time.monotonic() - result = token.wait(timeout=0.1) - elapsed = time.monotonic() - start - assert result is False - assert elapsed >= 0.1 - - -def test_sync_wait_interrupted_by_cancel() -> None: - """wait() should return True when cancelled during wait.""" - token = CancellationToken() - - def cancel_after_delay() -> None: - time.sleep(0.1) - token.cancel() - - thread = threading.Thread(target=cancel_after_delay) - thread.start() - - start = time.monotonic() - result = token.wait(timeout=1.0) - elapsed = time.monotonic() - start - - thread.join() - - assert result is True - assert elapsed < 0.5 # Should be much faster than timeout - - -@pytest.mark.asyncio -async def test_aio_wait_returns_when_cancelled() -> None: - """aio_wait() should return when cancelled.""" - token = CancellationToken() - - async def cancel_after_delay() -> None: - await asyncio.sleep(0.1) - token.cancel() - - asyncio.create_task(cancel_after_delay()) - - start = time.monotonic() - await token.aio_wait() - elapsed = time.monotonic() - start - - assert elapsed < 0.5 # Should be fast - - -def test_register_child() -> None: - """register_child() should add run IDs to the list.""" - token = CancellationToken() - token.register_child("run-1") - token.register_child("run-2") - - assert token.child_run_ids == ["run-1", "run-2"] - - -def test_callback_invoked_on_cancel() -> None: - """Callbacks should be invoked when cancel() is called.""" - token = CancellationToken() - called = [] - - def callback() -> None: - called.append(True) - - token.add_callback(callback) - token.cancel() - - assert called == [True] - - -def test_callback_invoked_immediately_if_already_cancelled() -> None: - """Callbacks added after cancellation should be invoked immediately.""" - token = CancellationToken() - token.cancel() - - called = [] - - def callback() -> None: - called.append(True) - - token.add_callback(callback) - - assert called == [True] - - -def test_multiple_callbacks() -> None: - """Multiple callbacks should all be invoked.""" - token = CancellationToken() - results: list[int] = [] - - token.add_callback(lambda: results.append(1)) - token.add_callback(lambda: results.append(2)) - token.add_callback(lambda: results.append(3)) - - token.cancel() - - assert results == [1, 2, 3] - - -def test_repr() -> None: - """__repr__ should provide useful debugging info.""" - token = CancellationToken() - token.register_child("run-1") - - repr_str = repr(token) - assert "cancelled=False" in repr_str - assert "children=1" in repr_str - - -# await_with_cancellation - - -@pytest.mark.asyncio -async def test_no_token_awaits_directly() -> None: - """Without a token, coroutine should be awaited directly.""" - - async def simple_coro() -> str: - return "result" - - result = await await_with_cancellation(simple_coro(), None) - assert result == "result" - - -@pytest.mark.asyncio -async def test_token_not_cancelled_returns_result() -> None: - """With a non-cancelled token, should return coroutine result.""" - token = CancellationToken() - - async def simple_coro() -> str: - await asyncio.sleep(0.01) - return "result" - - result = await await_with_cancellation(simple_coro(), token) - assert result == "result" - - -@pytest.mark.asyncio -async def test_already_cancelled_raises_immediately() -> None: - """With an already-cancelled token, should raise immediately.""" - token = CancellationToken() - token.cancel() - - async def simple_coro() -> str: - await asyncio.sleep(10) # Would block if actually awaited - return "result" - - with pytest.raises(asyncio.CancelledError): - await await_with_cancellation(simple_coro(), token) - - -@pytest.mark.asyncio -async def test_cancellation_during_await_raises() -> None: - """Should raise CancelledError when token is cancelled during await.""" - token = CancellationToken() - - async def slow_coro() -> str: - await asyncio.sleep(10) - return "result" - - async def cancel_after_delay() -> None: - await asyncio.sleep(0.1) - token.cancel() - - asyncio.create_task(cancel_after_delay()) - - start = time.monotonic() - with pytest.raises(asyncio.CancelledError): - await await_with_cancellation(slow_coro(), token) - elapsed = time.monotonic() - start - - assert elapsed < 0.5 # Should be cancelled quickly - - -@pytest.mark.asyncio -async def test_cancel_callback_invoked() -> None: - """Cancel callback should be invoked on cancellation.""" - token = CancellationToken() - callback_called = [] - - async def cancel_callback() -> None: - callback_called.append(True) - - async def slow_coro() -> str: - await asyncio.sleep(10) - return "result" - - async def cancel_after_delay() -> None: - await asyncio.sleep(0.1) - token.cancel() - - asyncio.create_task(cancel_after_delay()) - - with pytest.raises(asyncio.CancelledError): - await await_with_cancellation( - slow_coro(), token, cancel_callback=cancel_callback - ) - - assert callback_called == [True] - - -@pytest.mark.asyncio -async def test_sync_cancel_callback_invoked() -> None: - """Cancel callback should be invoked on cancellation.""" - token = CancellationToken() - callback_called = [] - - async def cancel_callback() -> None: - callback_called.append(True) - - async def slow_coro() -> str: - await asyncio.sleep(10) - return "result" - - async def cancel_after_delay() -> None: - await asyncio.sleep(0.1) - token.cancel() - - asyncio.create_task(cancel_after_delay()) - - with pytest.raises(asyncio.CancelledError): - await await_with_cancellation( - slow_coro(), token, cancel_callback=cancel_callback - ) - - assert callback_called == [True] - - -@pytest.mark.asyncio -async def test_cancel_callback_invoked_on_external_task_cancel() -> None: - """Cancel callback should be invoked if the awaiting task is cancelled externally.""" - token = CancellationToken() - callback_called = asyncio.Event() - - async def cancel_callback() -> None: - callback_called.set() - - async def slow_coro() -> str: - await asyncio.sleep(10) - return "result" - - task = asyncio.create_task( - await_with_cancellation(slow_coro(), token, cancel_callback=cancel_callback) - ) - - await asyncio.sleep(0.1) - task.cancel() - - with pytest.raises(asyncio.CancelledError): - await task - - await asyncio.wait_for(callback_called.wait(), timeout=1.0) - - -@pytest.mark.asyncio -async def test_cancel_callback_not_invoked_on_success() -> None: - """Cancel callback should NOT be invoked when coroutine completes normally.""" - token = CancellationToken() - callback_called = [] - - async def cancel_callback() -> None: - callback_called.append(True) - - async def fast_coro() -> str: - await asyncio.sleep(0.01) - return "result" - - result = await await_with_cancellation( - fast_coro(), token, cancel_callback=cancel_callback - ) - - assert result == "result" - assert callback_called == [] - - -# CancellationReason - - -def test_all_reasons_exist() -> None: - """All expected cancellation reasons should exist.""" - assert CancellationReason.USER_REQUESTED.value == "user_requested" - assert CancellationReason.TIMEOUT.value == "timeout" - assert CancellationReason.PARENT_CANCELLED.value == "parent_cancelled" - assert CancellationReason.WORKFLOW_CANCELLED.value == "workflow_cancelled" - assert CancellationReason.TOKEN_CANCELLED.value == "token_cancelled" - - -def test_reasons_are_strings() -> None: - """Cancellation reason values should be strings.""" - for reason in CancellationReason: - assert isinstance(reason.value, str) - - -# CancelledError - - -def test_cancelled_error_is_base_exception() -> None: - """CancelledError should be a BaseException (not Exception).""" - err = CancelledError("test message") - assert isinstance(err, BaseException) - assert not isinstance(err, Exception) # Should NOT be caught by except Exception - assert str(err) == "test message" - - -def test_cancelled_error_not_caught_by_except_exception() -> None: - """CancelledError should NOT be caught by except Exception.""" - caught_by_exception = False - caught_by_cancelled_error = False - - try: - raise CancelledError("test") - except Exception: - caught_by_exception = True - except CancelledError: - caught_by_cancelled_error = True - - assert not caught_by_exception - assert caught_by_cancelled_error - - -def test_cancelled_error_with_reason() -> None: - """CancelledError should accept and store a reason.""" - err = CancelledError("test message", reason=CancellationReason.TIMEOUT) - assert err.reason == CancellationReason.TIMEOUT - - -def test_cancelled_error_reason_defaults_to_none() -> None: - """CancelledError reason should default to None.""" - err = CancelledError("test message") - assert err.reason is None - - -def test_cancelled_error_message_property() -> None: - """CancelledError should have a message property.""" - err = CancelledError("test message") - assert err.message == "test message" - - -def test_cancelled_error_default_message() -> None: - """CancelledError should have a default message.""" - err = CancelledError() - assert err.message == "Operation cancelled" - - -def test_can_be_raised_and_caught() -> None: - """CancelledError should be raisable and catchable.""" - with pytest.raises(CancelledError) as exc_info: - raise CancelledError("Operation cancelled") - - assert "Operation cancelled" in str(exc_info.value) - - -def test_can_be_raised_with_reason() -> None: - """CancelledError should be raisable with a reason.""" - with pytest.raises(CancelledError) as exc_info: - raise CancelledError( - "Parent was cancelled", reason=CancellationReason.PARENT_CANCELLED - ) - - assert exc_info.value.reason == CancellationReason.PARENT_CANCELLED - - -# Context var propagation - - -def test_context_var_default_is_none() -> None: - """ctx_cancellation_token should default to None.""" - assert ctx_cancellation_token.get() is None - - -def test_context_var_can_be_set_and_retrieved() -> None: - """ctx_cancellation_token should be settable and retrievable.""" - token = CancellationToken() - ctx_cancellation_token.set(token) - try: - assert ctx_cancellation_token.get() is token - finally: - ctx_cancellation_token.set(None) - - -@pytest.mark.asyncio -async def test_context_var_propagates_in_async() -> None: - """ctx_cancellation_token should propagate in async context.""" - token = CancellationToken() - ctx_cancellation_token.set(token) - - async def check_token() -> CancellationToken | None: - return ctx_cancellation_token.get() - - try: - retrieved = await check_token() - assert retrieved is token - finally: - ctx_cancellation_token.set(None) diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index dbe8737704..87cdbb8d94 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Hatchet's TypeScript SDK will be documented in this chang The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.1] - 2026-02-18 + +### Fixed + +- Restored `ctx.taskRunId()` as a deprecated alias for `ctx.taskRunExternalId()` on both v0 and v1 worker contexts, so existing code calling `ctx.taskRunId()` continues to work after the proto naming changes in 1.11.0. + ## [1.12.0] - 2026-02-13 ### Added diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index bdc3fac97e..75d4342a70 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@hatchet-dev/typescript-sdk", - "version": "1.12.0", + "version": "1.12.1", "description": "Background task orchestration & visibility for developers", "types": "dist/index.d.ts", "files": [ diff --git a/sdks/typescript/src/step.ts b/sdks/typescript/src/step.ts index 51b2820c41..c50afec1e4 100644 --- a/sdks/typescript/src/step.ts +++ b/sdks/typescript/src/step.ts @@ -306,6 +306,15 @@ export class V0Context { return this.action.taskRunExternalId; } + /** + * Gets the ID of the current task run. + * @returns The task run ID. + * @deprecated use taskRunExternalId() instead + */ + taskRunId(): string { + return this.taskRunExternalId(); + } + /** * Gets the number of times the current task has been retried. * @returns The retry count. diff --git a/sdks/typescript/src/v1/client/worker/context.ts b/sdks/typescript/src/v1/client/worker/context.ts index c35703f3a6..356460f4e4 100644 --- a/sdks/typescript/src/v1/client/worker/context.ts +++ b/sdks/typescript/src/v1/client/worker/context.ts @@ -229,6 +229,15 @@ export class Context { return this.action.taskRunExternalId; } + /** + * Gets the ID of the current task run. + * @returns The task run ID. + * @deprecated use taskRunExternalId() instead + */ + taskRunId(): string { + return this.taskRunExternalId(); + } + /** * Gets the number of times the current task has been retried. * @returns The retry count. diff --git a/sql/schema/v1-core.sql b/sql/schema/v1-core.sql index 30528a9434..70d2d49650 100644 --- a/sql/schema/v1-core.sql +++ b/sql/schema/v1-core.sql @@ -443,6 +443,8 @@ CREATE TABLE v1_worker_slot_config ( PRIMARY KEY (tenant_id, worker_id, slot_type) ); +CREATE INDEX v1_worker_slot_config_worker_id_idx ON v1_worker_slot_config (worker_id); + -- v1_step_slot_request stores per-step slot requests. CREATE TABLE v1_step_slot_request ( tenant_id UUID NOT NULL,