Skip to content

feat: add lifecycle_status enum and trigger_source for automation runs - #438

Open
malhotra5 wants to merge 10 commits into
mainfrom
openhands/lifecycle-state
Open

feat: add lifecycle_status enum and trigger_source for automation runs#438
malhotra5 wants to merge 10 commits into
mainfrom
openhands/lifecycle-state

Conversation

@malhotra5

Copy link
Copy Markdown
Member

Summary

This is PR 1 of 2 in a stacked PR series that splits PR #417 (automation draft lifecycle dispatch) into smaller, independently reviewable pieces.

PR 1 (this PR): lifecycle_status + trigger_source foundation

Introduces the data model and plumbing for first-class automation lifecycle states and run trigger sources, without yet adding the draft-specific endpoints or synthetic-payload test dispatch (those come in PR 2).

PR 2 (follow-up): draft lifecycle endpoints + event test dispatch

Will add the drafts table, draft schemas/endpoints, event automation test dispatch in draft mode, and synthetic payload support for manual test runs. Stacked on top of this PR.


What this PR does

1. Deprecates enabled flag in favor of lifecycle_status enum

  • Adds AutomationState enum (ACTIVE/INACTIVE/DRAFT) to models.py
  • Adds lifecycle_status column on Automation (indexed, backfilled from enabled in migration)
  • Keeps enabled for backwards compatibility — only ACTIVE rows have enabled=True
  • All places that previously set enabled=False now also set lifecycle_status=INACTIVE:
    • router.py: create, update, delete
    • scheduler.py: disable invalid cron
    • utils/run.py: disable_automation
    • utils/unhealthy.py: _apply_disable
    • git_sync/loop.py: soft-delete on import

2. Adds trigger_source to AutomationRun

  • New trigger_source column (manual/cron/event/null for legacy)
  • Set to "manual" on dispatch endpoint, "cron" in scheduler, "event" in webhook handler
  • Manual test dispatches now work for both cron AND event automations, even when inactive
  • dispatcher.py: _poll_pending_runs now dispatches manual runs regardless of automation enabled/state
  • skip_pending_runs_for_disabled_automation excludes manual runs by default (include_manual=False); delete uses include_manual=True

3. Schema-level enforcement

  • AutomationState StrEnum in schemas.py
  • normalize_automation_state_enabled() model_validator keeps enabled and lifecycle_status consistent
  • lifecycle_status field added to CreateAutomationRequest, UpdateAutomationRequest, AutomationResponse, and both preset request models
  • trigger_source added to AutomationRunResponse

4. Scheduler/webhook filtering

  • _fetch_enabled_automations (scheduler) and get_event_automations/get_requested_event_types (webhook) now filter on lifecycle_status == ACTIVE in addition to enabled
  • Draft automations are never triggered automatically

5. Migration

  • 023_add_lifecycle_status_and_trigger_source.py: adds columns, indexes, and backfills lifecycle_status from enabled

Test plan

  • All 1714 existing unit tests pass (uv run pytest tests/ -q --ignore=tests/integration)
  • New test: test_dispatches_manual_run_for_disabled_automation — manual run dispatched even when automation is INACTIVE
  • New test: test_dispatch_disabled_automation_creates_manual_run — dispatch endpoint creates manual run instead of returning 409
  • New test: test_poll_excludes_draft_even_if_enabled_flag_is_true — draft automations never scheduled
  • New test: test_excludes_draft_automations — draft automations excluded from event matching
  • Removed tests for the old "only creator can edit" restriction (relaxed in favor of draft workflow)

This PR was created by an AI agent (OpenHands) on behalf of the user.

@malhotra5 can click here to continue refining the PR

Introduce a first-class lifecycle_status column (ACTIVE/INACTIVE/DRAFT)
on the Automation model, deprecating the boolean enabled flag while
keeping it for backwards compatibility. Add a trigger_source column to
AutomationRun (manual/cron/event) so manual test dispatches are no
longer blocked when an automation is inactive or in draft.

Key changes:
- models.py: AutomationState enum, lifecycle_status column on Automation,
  trigger_source on AutomationRun, new DB indexes
- schemas.py: AutomationState StrEnum, DraftEndpoint type, helpers
  (automation_state_enabled, normalize_automation_state_enabled),
  lifecycle_status fields on create/update/response schemas
- router.py: bridge helpers, set lifecycle_status on create/update/delete,
  remove enabled check on dispatch, set trigger_source='manual' on dispatch
- preset_router.py: lifecycle_status on prompt/plugin preset requests
- scheduler.py: filter ACTIVE lifecycle, set trigger_source='cron'
- dispatcher.py: dispatch manual runs even for inactive automations
- utils/run.py: skip_pending_runs excludes manual runs unless include_manual,
  create_pending_run accepts trigger_source
- utils/webhook.py: filter ACTIVE lifecycle, set trigger_source='event'
- utils/unhealthy.py: set lifecycle_status=INACTIVE on disable
- git_sync: serialize/import lifecycle_status alongside enabled
- migration 023: add columns and backfill from enabled flag

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions github-actions Bot added the type: feat A new feature label Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Coverage

Warning

Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it.

Mark the legacy `enabled` field as deprecated across all automation
schemas and emit a runtime DeprecationWarning when it is explicitly
set in request bodies. Callers should migrate to lifecycle_status.

- CreateAutomationRequest.enabled, UpdateAutomationRequest.enabled,
  and AutomationResponse.enabled: Field(deprecated=True) so the
  OpenAPI schema marks them deprecated for API consumers
- normalize_automation_state_enabled: emits a DeprecationWarning
  when 'enabled' is present in request data
- models.py: updated comment to say 'Deprecated' explicitly

Co-authored-by: openhands <openhands@all-hands.dev>
malhotra5 pushed a commit that referenced this pull request Sep 11, 2026
…payloads

Add the draft lifecycle layer on top of the lifecycle_status foundation
from PR #438:

- draft_schemas.py: partial draft body models for all three creation
  endpoints (/v1, /v1/preset/prompt, /v1/preset/plugin), draft body
  normalization, and FINAL_DRAFT_MODELS registry
- draft_router.py: full CRUD for server-backed drafts
  (POST/GET/PATCH/DELETE /v1/drafts), draft validation, materialization
  into DRAFT-state automations, and POST /v1/drafts/{id}/dispatch for
  test runs. Dispatch accepts an optional event_payload so event-triggered
  draft automations can be test-run with a user-supplied synthetic payload,
  bypassing webhook signature verification (caller is authenticated).
- models.py: AutomationDraft model with source/materialized/last_test_run
  foreign keys
- schemas.py: CreateAutomationDraftRequest, UpdateAutomationDraftRequest,
  AutomationDraftResponse, AutomationDraftListResponse, DraftDispatchRequest
- capabilities_router.py: use draft_schemas registry instead of inline
  _DRAFT_MODELS; add automationDrafts to static features
- app.py: register draft_router
- utils/run.py: create_pending_run accepts optional event_payload
- migration 024: automation_drafts table

Stacked on PR #438 (lifecycle_status + trigger_source).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@malhotra5
malhotra5 marked this pull request as ready for review September 11, 2026 16:03
@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review: feat: add lifecycle_status enum and trigger_source for automation runs

🟡 Acceptable — Works well, a few design concerns worth addressing.

[Security/Design] Removed creator-only edit restriction

The PR removes the check in update_automation that prevented non-creators from editing automation definitions. The old comment was explicit: "Automations run under their creator's identity (git tokens, secrets, MCP servers), so only the creator may change what they do." Now any admin/owner with manage_automations can modify a teammate's automation tarball_path, entrypoint, or prompt — causing it to execute arbitrary code under that teammate's API key and secrets on the next run.

This may be intentional for the draft workflow (PR 2), but the PR description only says "relaxed in favor of draft workflow" without explaining why it is safe. The risk: an admin can now run code under another user's identity without that user's knowledge. If this is deliberate, consider documenting the rationale; if not, the restriction should be preserved for non-lifecycle fields.

Additionally, the _assert_can_manage docstring (lines 121-122) is now stale — it still claims "only the creator may change an automation's definition; everyone else may only turn it off" which is no longer enforced.

[Dead Code] DraftEndpoint literal is unused

DraftEndpoint = Literal["/v1", "/v1/preset/prompt", "/v1/preset/plugin"] in schemas.py is defined but never imported or referenced anywhere. Remove it or it will confuse readers and linters.

[DRY] Duplicated helper functions

_model_automation_state and _automation_state_enabled are copy-pasted identically into both router.py and preset_router.py. Extract to a shared module (e.g., schemas.py next to normalize_automation_state_enabled, or a utils/state.py).

What looks good

  • Migration is clean and cross-database compatible (generic sa.String, no PG-specific types).
  • The dispatcher query (or_(manual, and_(enabled, ACTIVE))) correctly lets manual runs through while blocking non-manual runs for inactive/draft automations.
  • skip_pending_runs_for_disabled_automation with include_manual correctly preserves manual runs on disable while skipping them on delete.
  • Schema-level normalize_automation_state_enabled validator keeps enabled and lifecycle_status consistent — good defensive design.
  • New tests cover the key scenarios (manual dispatch for disabled automation, draft exclusion from scheduler/webhook).

[RISK ASSESSMENT]

  • Overall PR: 🟡 MEDIUM — The removed edit restriction is security-adjacent (an admin can now run code under another user's identity by editing their automation definition), but is limited to manage_automations-level users who are already highly trusted. The core lifecycle/trigger_source logic is sound and well-tested.

VERDICT: ✅ Worth merging — Core logic is sound, the security concern is limited to admin-level users and may be intentional for the draft workflow. Address the stale docstring and dead code in a follow-up.

KEY INSIGHT: The dual enabled/lifecycle_status model is well-implemented with consistent cross-updates, but the security relaxation of allowing non-creators to edit automation definitions deserves explicit justification.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread openhands/automation/router.py
Comment thread openhands/automation/schemas.py Outdated
Comment thread openhands/automation/router.py Outdated
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@all-hands-bot

Copy link
Copy Markdown
Contributor

🤖 OpenHands is reviewing this PR.

Head commit: aa6a0dea23ab411c2df910bebc7825e371c50fcc
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/f576e6d7-d2fe-4af3-ba75-2facaeb24eaa

This comment was posted by an AI agent (OpenHands).

all-hands-bot
all-hands-bot previously approved these changes Sep 11, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review Summary

PR 1 of 2: lifecycle_state + trigger_source foundation

This PR introduces a first-class state enum (ACTIVE/INACTIVE/DRAFT) on the Automation model and a trigger_source column on AutomationRun, while keeping the legacy enabled boolean for backward compatibility. The implementation is clean, consistent, and well-tested.

What was verified

  • Migration (023_add_state_and_trigger_source.py): Cross-database compatible (uses sa.String, no PG-specific types). Backfill via CASE WHEN enabled THEN 'ACTIVE' ELSE 'INACTIVE' END is correct — no existing row can be DRAFT. Indexes on state and trigger_source (plus composite status, trigger_source) are appropriate for the dispatcher and scheduler queries.
  • State/enabled consistency: Every site that sets enabled=False also sets state=INACTIVErouter.py (create, update, delete), scheduler.py (invalid cron), utils/run.py (disable_automation), utils/unhealthy.py (_apply_disable), git_sync/loop.py (soft-delete). No site was missed.
  • Dispatcher query: or_(trigger_source == "manual", and_(enabled.is_(True), state == ACTIVE)) correctly allows manual runs to proceed regardless of automation state, while cron/event runs require both enabled=True and state=ACTIVE. Deleted automations are still excluded via deleted_at.is_(None).
  • Schema validation: normalize_automation_state_enabled correctly rejects conflicting state+enabled pairs (verified: state=DRAFT, enabled=True raises ValidationError). When only one field is sent, the other is derived consistently.
  • Permission model relaxation: Non-creator admins can now toggle state/enabled but still cannot edit the automation definition (tarball, entrypoint, trigger, etc.). _assert_can_update_fields with _STATE_ONLY_UPDATE_FIELDS correctly enforces this. This is a reasonable scope expansion — admins with manage_automations can already delete automations.
  • skip_pending_runs_for_disabled_automation: include_manual=False by default preserves manual runs when an automation is system-disabled (unhealthy, auth failures). include_manual=True on delete correctly skips all pending runs including manual ones, since a deleted automation can no longer be dispatched.
  • Scheduler/webhook filtering: Both _fetch_enabled_automations and get_event_automations/get_requested_event_types now filter on state == ACTIVE in addition to enabled == True. Draft automations are never triggered automatically. Verified by tests.
  • Test coverage: New tests cover the key behavioral changes — manual dispatch for disabled automations, draft exclusion from scheduler and webhook matching, non-creator state updates, and the dispatcher picking up manual runs for INACTIVE automations.

Non-blocking observations (included for awareness, not requiring changes)

  1. System-disabled automations can be manually dispatched: Removing the 409 check on the dispatch endpoint means automations disabled by the unhealthy watchdog or auth failures can now be manually triggered. This is intentional per the PR description and is the foundation for the draft test-dispatch workflow in PR 2. The previous behavior of surfacing disabled_reason/disabled_detail to the caller is lost, but manual dispatch is an explicit user action.

  2. DeprecationWarning on enabled: normalize_automation_state_enabled emits a DeprecationWarning whenever enabled appears in a request body. Python's default warning filter suppresses DeprecationWarning in production, so this is unlikely to cause log noise, but clients should migrate to state as indicated.

  3. Duplicate AutomationState definitions: models.py defines AutomationState(enum.Enum) and schemas.py defines AutomationState(StrEnum). This mirrors the existing AutomationRunStatus/RunStatus pattern and is consistent with the codebase.

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    • No breaking changes to existing API behavior (backward compatibility maintained via enabled flag)
    • Migration is additive (new columns with safe defaults, no data loss)
    • Permission relaxation is scoped to state/enabled toggles only
    • All state transitions are covered with tests

VERDICT: ✅ Worth merging

KEY INSIGHT: The dual-column approach (state + enabled) with a single source of truth (state) and derived enabled is a pragmatic migration strategy that avoids breaking existing clients while introducing the richer lifecycle model.

@all-hands-bot
all-hands-bot dismissed their stale review September 11, 2026 17:13

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Contributor

Posted an APPROVED review to PR #438 (OpenHands/automation).

Review Summary: The PR introduces a first-class state enum (ACTIVE/INACTIVE/DRAFT) on the Automation model and a trigger_source column on AutomationRun, while maintaining backward compatibility with the legacy enabled boolean.

Key findings: No material issues. The implementation is thorough and consistent:

  • Migration is cross-database compatible with correct backfill
  • Every enabled=False site also sets state=INACTIVE
  • Dispatcher query correctly allows manual runs regardless of automation state
  • Schema validation rejects conflicting state+enabled pairs (verified programmatically)
  • Permission relaxation is properly scoped to state/enabled toggles only
  • Tests cover all key behavioral changes

Risk Assessment: 🟢 LOW — no breaking changes, additive migration, scoped permission relaxation.

Verdict: ✅ Worth merging — approved per the custom code review guide since there are no blocking issues and risk is LOW.

This comment was posted by an AI agent (OpenHands).

@enyst

enyst commented Sep 14, 2026

Copy link
Copy Markdown
Member

@malhotra5 I had Astra make a pass, and the results were:

I would fix these before merging the stack. Reviewed #438⁠ at aa6a0de and #439⁠ at e33a93b.

  1. P1 — feat: add automation draft lifecycle, endpoints, and synthetic event payloads #439 permits replacement code to execute under another user’s identity.
    After Alice test-dispatches her draft, another admin can edit it and dispatch again. Reusing the materialized automation⁠ preserves Alice’s user_id while replacing its code. I reproduced the normal edit endpoint returning 403, the draft route succeeding, and the execution-key request still targeting Alice. Enforce creator ownership before modifying or reusing that artifact.
  2. P1 — Both migration IDs collide with current main.
    The stack introduces revisions 023⁠ and 024⁠, which main already uses for other migrations. Combining the files produces duplicate revisions, and Alembic rejects head. Renumber and reconnect both migrations after updating the stack.
  3. P2 — The normal API bypasses draft validation.
    Test-dispatch a complete draft, then edit it into an incomplete one. The draft endpoint correctly returns 422, but the ordinary dispatch endpoint⁠ still queues its old configuration with 201. A normal PATCH also activates that stale configuration. Draft artifacts need validation against the current draft before dispatch or finalization.
  4. P2 — A second test dispatch changes what an already queued test executes.
    Dispatch version A, edit to B, and dispatch again before the dispatcher polls: both runs load B. The shared automation row is overwritten⁠. Preserve each run’s execution configuration, or prevent reuse while an earlier run remains pending.
  5. P2 — Git sync ignores ACTIVE and INACTIVE state values.
    Import derives these states from ⁠enabled⁠. I imported state: INACTIVE without enabled; it became ACTIVE and was selected by the scheduler. Changing an exported draft’s state to ACTIVE can conversely leave it inactive. Use consistent state normalization across Git and API inputs.

One additional behavior needs a decision: two failed draft tests plus the first production failure trigger automatic disabling after activation; test history remains in the failure count.

Targeted reproductions exposed the issues above.

They look plausible to me, but I think maybe some are actually reasonable behaviors: for one, point 4 sounds like pretty good behavior to me. If I dispatched, and then was like, actually let me tweak this prompt, then I’d expect any later run to get the tweak. So new stuff applies.
Point 3 seems like just that: I’d want the old configuration to no longer apply, only the new one. So I’d personally fix 3 and not fix 4.
Not sure about Point 1.

I can ask the agent to post its repro tests, so we see them red?

Reproduce Git state import errors and collisions with pinned published
Alembic history. Include passing compatibility controls and offline run
instructions. Production code is unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed when the PR is approved.

Co-authored-by: openhands <openhands@all-hands.dev>
malhotra5 pushed a commit that referenced this pull request Sep 14, 2026
…payloads

Add the draft lifecycle layer on top of the lifecycle_status foundation
from PR #438:

- draft_schemas.py: partial draft body models for all three creation
  endpoints (/v1, /v1/preset/prompt, /v1/preset/plugin), draft body
  normalization, and FINAL_DRAFT_MODELS registry
- draft_router.py: full CRUD for server-backed drafts
  (POST/GET/PATCH/DELETE /v1/drafts), draft validation, materialization
  into DRAFT-state automations, and POST /v1/drafts/{id}/dispatch for
  test runs. Dispatch accepts an optional event_payload so event-triggered
  draft automations can be test-run with a user-supplied synthetic payload,
  bypassing webhook signature verification (caller is authenticated).
- models.py: AutomationDraft model with source/materialized/last_test_run
  foreign keys
- schemas.py: CreateAutomationDraftRequest, UpdateAutomationDraftRequest,
  AutomationDraftResponse, AutomationDraftListResponse, DraftDispatchRequest
- capabilities_router.py: use draft_schemas registry instead of inline
  _DRAFT_MODELS; add automationDrafts to static features
- app.py: register draft_router
- utils/run.py: create_pending_run accepts optional event_payload
- migration 024: automation_drafts table

Stacked on PR #438 (lifecycle_status + trigger_source).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>

Copy link
Copy Markdown
Member Author

@enyst update on the review pass:

Addressed in the stack:

  1. Point 2 — migration revision collisions

  2. Point 3 — normal API bypassing current draft validation

    • Addressed in feat: add automation draft lifecycle, endpoints, and synthetic event payloads #439.
    • Normal dispatch and normal activation now guard materialized draft artifacts by checking the current draft record instead of blindly using the stale materialized automation row.
    • If the current draft is invalid, normal dispatch/activation returns the same 422 validation-error shape as draft dispatch.
    • If there is no current draft context, normal API use of draft artifacts is rejected so callers must go through the draft API.
  3. Point 5 — Git sync ignoring ACTIVE / INACTIVE state

Intentionally not addressed:

  1. Point 4 — second test dispatch changes what a not-yet-started queued test executes
    • We are keeping this behavior. The desired behavior is that edits made before a queued draft test is actually dispatched can affect that not-yet-started run.
    • I removed/left out regression coverage that asserted the opposite, so CI is not protecting against this desired behavior.

Not addressed in this pass:

  1. Point 1 — cross-admin replacement under the original materialized automation owner
    • I did not change this yet because it needed a product/security decision. The current pass focused on the points we were sure about: 2, 3, and 5, while explicitly preserving point 4 behavior.

Current CI status after the latest pushes:

This comment was created by an AI agent (OpenHands) on behalf of the user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants