diff --git a/.agents/skills/automations/SKILL.md b/.agents/skills/automations/SKILL.md index bdda949bf5..2fbe2091b5 100644 --- a/.agents/skills/automations/SKILL.md +++ b/.agents/skills/automations/SKILL.md @@ -1,9 +1,9 @@ --- name: automations description: >- - Event-triggered and schedule-triggered automations with natural-language - conditions. Use when creating automations, wiring events, or understanding - how triggers fire. + Manual, event-triggered, and schedule-triggered automations with optional + event conditions. Use when creating automations, wiring events, running one + on demand, or understanding how triggers fire. metadata: internal: true --- @@ -12,11 +12,11 @@ metadata: ## Rule -Automations are the user-facing umbrella for agent-executed tasks that fire in -response to events or on a cron schedule. **Scheduled** and **Event** are the -two trigger types. Each automation is a markdown resource under `jobs/` with -YAML frontmatter describing when and how it fires, and a body containing -natural-language instructions the agent follows. +Automations are the user-facing umbrella for agent-executed tasks that run on +demand, in response to events, or on a cron schedule. **Schedule**, **Event**, +and **Manual** are the three persisted trigger types. Each automation is a +markdown resource under `jobs/` with YAML frontmatter describing when and how +it fires, and a body containing natural-language instructions the agent follows. Recurring Jobs is the legacy name and API for scheduled automations. `manage-jobs`, `jobs/`, and `/agent#jobs` remain stable compatibility surfaces. @@ -24,39 +24,53 @@ Use `manage-automations` for new personal or organization automations, including per-automation model overrides and MCP allowlists. Keep `manage-jobs` for existing schedule-only integrations and delivery metadata. -## The Two Trigger Types +## The Three Persisted Trigger Types -| Type | Fires when | Key field | -| ---------- | ----------------------------------------------- | ------------------- | -| `schedule` | Cron expression matches (same as recurring jobs) | `schedule` (cron) | -| `event` | A matching event is emitted on the event bus | `event` (event name) | +| Type | Fires when | Trigger-specific fields | +| --- | --- | --- | +| `schedule` | Cron expression matches (same as recurring jobs) | `schedule` (required), `timezone` | +| `event` | A matching event is emitted on the event bus | `event` (required), `condition` (optional) | +| `manual` | A user explicitly invokes `run-now` | None | -Event triggers can optionally include a `condition` -- a natural-language string evaluated by Haiku against the event payload before dispatch. If the condition does not match, the automation is skipped. +Event triggers can optionally include a `condition` -- a natural-language +string evaluated against the event payload before dispatch. If the condition +does not match, the automation is skipped. Manual automations have no +`schedule`, `timezone`, `event`, or `condition`; they never fire from the +scheduler or event bus and run only through `manage-automations` +`action=run-now`. + +The UI's **Email received** choice is not a fourth persisted trigger type. It is +a specialized event editor that persists `triggerType: event` with +`event: mail.message.received` and represents email filters as the event +condition. ## How It Works 1. User asks the agent to create an automation (or uses the settings UI). -2. Agent calls `manage-automations` with `action=list-events` to discover available events. -3. Agent calls `manage-automations` with `action=define` to write a `jobs/.md` resource. -4. The trigger dispatcher subscribes to the event on the bus. -5. When the event fires, the dispatcher loads all matching triggers, enforces - owner and organization scope, and evaluates conditions via Haiku. -6. Event and cron acquisition converge on the shared background-automation - runner, which validates identity, resolves the configured model and MCP - allowlist, runs the agent loop, handles continuation and delivery, and - records usage. -7. Status (`lastRun`, `lastStatus`, `lastError`) is written back to the resource frontmatter. +2. For an event trigger, the agent calls `manage-automations` with + `action=list-events` to discover the exact registered event name and payload. +3. Agent calls `manage-automations` with `action=define` to write a + `jobs/.md` resource. +4. The scheduler acquires due schedule triggers, the event dispatcher acquires + matching event triggers, and manual triggers wait for `action=run-now`. +5. Event acquisition enforces owner and organization scope and evaluates any + condition against the event payload. +6. Schedule, event, and explicit run-now acquisition converge on the shared + background-automation runner, which validates identity, resolves the + configured model and MCP allowlist, runs the agent loop, handles continuation + and delivery, and records usage. +7. Status (`lastRun`, `lastStatus`, `lastError`) is written back to the resource + frontmatter. Trigger acquisition stays separate by design: the scheduler decides when a cron -expression is due, while the event dispatcher matches event names, owners, and -conditions. Everything after a trigger is accepted uses the same execution -lifecycle. +expression is due, the event dispatcher matches event names, owners, and +conditions, and `run-now` is the only acquisition path for manual automations. +Everything after a trigger is accepted uses the same execution lifecycle. ## Markdown Format ```yaml --- -schedule: "" enabled: true triggerType: event event: calendar.booking.created @@ -77,9 +91,9 @@ Use the web-request tool with ${keys.SLACK_WEBHOOK}. | ------------- | ------------------------------ | ------------------------------------------------------ | | `schedule` | `string` | Cron expression (required for schedule triggers) | | `enabled` | `boolean` | Whether the automation is active | -| `triggerType` | `"schedule" \| "event"` | How the automation fires | -| `event` | `string?` | Event name to subscribe to (event triggers) | -| `condition` | `string?` | Natural-language condition evaluated before dispatch | +| `triggerType` | `"schedule" \| "event" \| "manual"` | How the automation fires | +| `event` | `string?` | Event name to subscribe to (event triggers only) | +| `condition` | `string?` | Natural-language event condition (event triggers only) | | `mode` | `"agentic"` | Full agent loop (only supported mode; `"deterministic"` was removed — never implemented, rejected at define time) | | `model` | `string?` | Override the model for this trigger's agent loop | | `domain` | `string?` | Grouping tag (mail, calendar, clips, etc.) | @@ -97,22 +111,32 @@ All automation operations are accessed through a single `manage-automations` too | Action | Purpose | | ------------- | -------------------------------------------------------------------- | -| `list-events` | Discover all registered events with descriptions and payload schemas | -| `list` | List all automations with status, filter by domain or enabled | -| `define` | Create a new automation (name, trigger type, event, condition, body) | -| `update` | Update an existing automation (enabled, condition, body) | -| `delete` | Delete an automation (always confirm with user first) | -| `fire-test` | Emit a `test.event.fired` event to validate automations | -| `run-now` | Run one automation immediately with its real actions and side effects | +| `list-events` | Discover registered event names, descriptions, and payload schemas before defining or changing an event trigger | +| `list` | List automations and their trigger, status, model, tools, and delivery metadata | +| `define` | Create an automation after confirming the summary with the user; requires `name`, `trigger_type`, and `body` | +| `update` | Update an existing automation in its original scope without changing its creator | +| `delete` | Delete an automation (always confirm with user first) | +| `fire-test` | Emit a `test.event.fired` event to validate event automations | +| `run-now` | Explicitly run one automation immediately with real actions and side effects | + +Use `list-events` only when selecting an event trigger; schedule and manual +triggers do not need event discovery. For **Email received**, use the registered +`mail.message.received` event rather than inventing an email trigger type. + +For `define` and `update`, send `trigger_type: schedule` with a cron `schedule`, +`trigger_type: event` with an exact registered `event` and optional `condition`, +or `trigger_type: manual` with none of those trigger-specific fields. Pass the +same `scope` when updating an existing automation. `manage-automations` supports +`model` and `mcpTools` on define/update; the MCP allowlist is enforced, not +advisory. + +Use `run-now` only for an explicit user-authorized execution. It can run any +persisted trigger type immediately, is the sole execution path for manual +automations, returns a durable run id, and does not change the next scheduled +run. Additional tool: `web-request` — outbound HTTP with `${keys.NAME}` substitution. -`manage-automations` accepts personal or organization scope and supports -`model` and `mcpTools` on define/update. An MCP allowlist is enforced, not -advisory: every named tool must resolve in the creator's request context or the -run fails clearly, and the runner never widens access beyond the configured -names. - ## Organization Event Automations Organization event automations are visible to organization members but always @@ -199,10 +223,10 @@ Automations use the `web-request` tool for outbound HTTP. It supports `${keys.NA ## UI The full-page Agent surface's **Automations** tab is the primary management -surface for scheduled and event-triggered automations. Users can view status, -enable/disable, inspect, and delete automations there. Its URL remains -`/agent#jobs` for compatibility even though the visible tab is Automations. -Creation typically happens through the agent chat. +surface for schedule, event, and manual automations. Users can create, edit, +run now, view status, enable/disable, inspect, and delete automations there. Its +URL remains `/agent#jobs` for compatibility even though the visible tab is +Automations. ## Example @@ -228,7 +252,7 @@ Agent flow: | `packages/core/src/triggers/types.ts` | `TriggerFrontmatter` interface | | `packages/core/src/triggers/actions.ts` | Agent tools (define, list, update, delete, test) | | `packages/core/src/triggers/dispatcher.ts` | Event subscription and agentic dispatch | -| `packages/core/src/jobs/background-automation-runner.ts` | Shared schedule/event execution lifecycle | +| `packages/core/src/jobs/background-automation-runner.ts` | Shared schedule/event/manual execution lifecycle | | `packages/core/src/triggers/condition-evaluator.ts` | Haiku condition classification with caching | | `packages/core/src/event-bus/` | Event bus (register, emit, subscribe) | | `packages/core/src/tools/fetch-tool.ts` | `web-request` tool with key substitution | diff --git a/.changeset/friendly-automation-editor-actions.md b/.changeset/friendly-automation-editor-actions.md new file mode 100644 index 0000000000..45858de470 --- /dev/null +++ b/.changeset/friendly-automation-editor-actions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add a friendly trigger-first automation editor with on-demand automations, registered event and email selection, and reusable friendly schedule fields while retaining advanced cron support. diff --git a/.changeset/quiet-automation-sharing-store.md b/.changeset/quiet-automation-sharing-store.md new file mode 100644 index 0000000000..495fe9a5d5 --- /dev/null +++ b/.changeset/quiet-automation-sharing-store.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add dialect-portable automation sharing storage and transaction-scoped resource write primitives. diff --git a/docs/plans/2026-08-05-automation-sharing-design.md b/docs/plans/2026-08-05-automation-sharing-design.md new file mode 100644 index 0000000000..7e7cc569b7 --- /dev/null +++ b/docs/plans/2026-08-05-automation-sharing-design.md @@ -0,0 +1,273 @@ +# Automation Sharing Design + +**Date:** 2026-08-05 +**Status:** Approved + +## Summary + +Automations become individually shareable without changing their underlying `jobs/*.md` resource definitions or execution model. The Automations page presents one access-aware list instead of separate Personal and Organization sections. Each row explains who can access the automation and which operations the current user may perform. + +Sharing is private by default, supports organization-wide view access or grants to specific existing accounts, and never creates a public link. A collaborator can edit and operate an automation, but the automation always executes as its immutable creator. Only the owner can delete the automation or change its sharing. + +## Product Decisions + +### One unified Automations list + +The Automations page has one list containing every automation the signed-in account may access: + +- automations the account owns; +- organization-visible automations for any organization of which the account is a current member; +- automations explicitly shared with the account; +- legacy recurring jobs admitted by the compatibility rules below. + +There are no Personal and Organization sections. Trigger type, enabled state, run status, and next/last run remain visible as they are today. + +### Row-level access labels + +Every row explains access independently of its trigger and status badges: + +- **Personal** — owned by the current user and not shared. +- **Organization** — visible to all current members of the owning organization with View access. +- **Shared with you · View** — explicitly shared with the current user as View. +- **Shared with you · Collaborate** — explicitly shared with the current user as Collaborate. +- **Specific people** for an owned automation — show the number of grantees and a compact avatar group when profile data is available. The count remains authoritative when avatars are unavailable or exceed the displayed limit. + +The row does not imply execution identity. A shared row may be operated by a collaborator, but execution remains creator-bound. + +### Sharing choices in create and edit + +Create and edit include a **Sharing** section with three mutually exclusive choices: + +1. **Personal** — only the owner can access the automation. +2. **Organization** — every current member of the selected owning organization receives View access through visibility; organization membership alone never grants edit, pause/resume, run-now, delete, or sharing management. +3. **Specific people** — the owner selects one or more existing accounts and assigns each View or Collaborate. + +Changing the selected mode replaces the prior sharing state atomically: + +- Personal stores private visibility and no grants. +- Organization stores organization visibility and no specific-user grants. +- Specific people stores private visibility plus the submitted user grants. + +A validation or write failure leaves both the job definition and the previous sharing state unchanged. The UI keeps the dialog open and shows an inline error. + +### Roles and permissions + +| Capability | View | Collaborate | Owner | +| --- | ---: | ---: | ---: | +| Appear in unified list | Yes | Yes | Yes | +| Open details and instructions | Yes | Yes | Yes | +| View enabled state, status, next/last run, errors, and run history | Yes | Yes | Yes | +| Edit definition | No | Yes | Yes | +| Pause or resume | No | Yes | Yes | +| Run now | No | Yes | Yes | +| Delete | No | No | Yes | +| Change visibility, users, or roles | No | No | Yes | + +Organization visibility is always View. It does not inherit an organization admin's current legacy mutation authority. The sharing model intentionally has no share-admin role: ownership is the only authority for deletion and sharing management. + +### Specific-user eligibility + +A specific user may be any existing Agent Native account, whether or not that account belongs to the owner's active organization. + +- Search results clearly mark accounts outside the owning organization. +- An outside-organization View grant may be saved normally. +- An outside-organization Collaborate grant requires an explicit acknowledgement in the current save attempt. The acknowledgement explains that the person can edit, pause/resume, and run the automation while it continues to execute with the creator's identity and connected capabilities. +- The acknowledgement is not persisted as a durable bypass; changing the selected outside collaborator or changing View to Collaborate requires a fresh acknowledgement. +- Unknown emails and nonexistent accounts fail validation. Sharing does not silently create or invite an account. + +There are no public links and no public visibility option. Direct URLs still require authentication and an access decision. + +## Data and Storage Design + +### Keep `jobs/*.md` as the definition source of truth + +Automation definitions remain markdown resources under `jobs/` with their existing YAML frontmatter and body. Sharing does not move, duplicate, or rewrite trigger definitions into a new domain table. Schedule, event, and manual acquisition continue to read the same resources. + +The resource id is the stable key for access. Name and owner remain compatibility locators, but permissions must not be keyed by a mutable or reusable path alone. + +### Add a job-specific sharing overlay + +Add core-owned, dialect-portable SQL storage keyed by `resources.id`: + +- one overlay row per job resource, containing private/organization visibility and the organization used for organization visibility; +- zero or more user-grant rows, each containing the resource id, normalized account email, and `view` or `collaborate` role; +- uniqueness on resource id for the overlay and on `(resource_id, user_email)` for grants; +- indexes for visibility/organization listing and user-grant listing. + +The overlay is specific to `jobs/*.md`. It must not reinterpret the generic resource `visibility` field, which currently distinguishes workspace resources from agent scratch resources, and it must not force resource definitions through the Drizzle shareable-resource registry designed for ownable app tables. + +Initialization is additive and idempotent for SQLite, Postgres, and D1 through the existing `getDbExec()`, `ensureTableExists`, `ensureColumnExists`, and `ensureIndexExists` patterns. No resource row or frontmatter migration is required. + +### Effective owner and creator + +The access service resolves the owner from the existing resource and parsed job frontmatter: + +- a personal resource is owned by its resource-owner email; +- an organization-owned resource is owned by its immutable `createdBy` account; +- a malformed resource whose creator cannot be determined fails closed for mutation and execution; +- sharing never changes `createdBy`, `runAs`, resource owner, organization id, or run-history ownership. + +New definitions continue to persist `createdBy` and `runAs: creator`. Shared run-now, schedule, and event execution all resolve the creator exactly as the current background runner does. + +### Legacy compatibility + +Existing organization-owned jobs that do not yet have an overlay row retain their current organization-visible behavior. They are listed to current members as Organization and are treated as a compatibility state rather than being rewritten eagerly. + +On the first owner-managed sharing update, an explicit overlay is written. There is no destructive or bulk migration. Legacy personal jobs without an overlay remain Personal. Existing `__shared__` compatibility resources continue to follow the current legacy acquisition rules until separately retired; this feature does not broaden their execution identity. + +## Access Service + +Introduce one job-specific access boundary used by all list, read, and mutation paths. It returns an explicit effective role (`owner`, `collaborate`, or `view`) and sharing summary instead of overloading the current `canUpdate` boolean. + +### Listing + +The unified list reads all candidate `jobs/*.md` resources and admits only resources for which one of these is true: + +- caller is the owner; +- caller is a current member of the overlay's organization and visibility is Organization; +- caller has a specific user grant; +- the resource qualifies for legacy organization-visible compatibility. + +Listing must batch overlay rows, grants, membership checks, and profiles rather than issue one SQL request per automation. It returns only fields the list needs plus the sharing summary. Full details and run history remain on-demand reads. + +### Authorization + +Every operation re-resolves access on the server; UI affordances are not an authorization boundary. + +- list/details/status/run-history require View; +- edit and pause/resume require Collaborate; +- run-now requires Collaborate and still dispatches as the creator; +- delete requires Owner; +- replace sharing requires Owner. + +Resource-not-found and inaccessible-resource reads should not leak existence. Membership and account lookups fail closed when unreadable. Revoked grants and removed organization membership take effect on the next action call and list refresh. + +### Atomic writes + +Create and edit validate the complete definition and sharing request before any write. Definition and sharing changes commit in one database transaction. The implementation must not report success when only one side was persisted. + +Delete removes the resource, run history, overlay, and grants in one transaction or with an equivalent fail-loud atomic boundary supported by the shared database abstraction. Name reuse must not inherit stale sharing or run history. + +The save contract accepts the intended complete sharing state rather than a sequence of independent client-side add/remove requests. This prevents intermediate access states and makes mode changes atomic. + +## Actions and Agent/UI Parity + +### Agent surface + +`manage-automations` remains the canonical conversational tool. It gains the same unified, resource-id-aware access behavior and sharing vocabulary as the UI: + +- `list` returns all accessible automations with effective access and sharing summary; +- `define` accepts Personal, Organization, or Specific people sharing after confirmation; +- `update`, pause/resume behavior, and `run-now` require Collaborate; +- `delete` and sharing changes require Owner; +- outside-organization Collaborate requires an explicit acknowledgement argument tied to that requested write. + +The tool description must teach the permission model, public-sharing exclusion, and creator-bound execution. The agent must not claim that sharing transfers credentials or execution ownership. + +### Direct UI actions + +The Agent page continues to call frontend-only actions discovered through the core action registry. Replace the split-scope reads with one unified access-aware list action and use stable resource ids for reads and mutations. The direct UI action surface must enforce the same access service as `manage-automations`; it must not duplicate authorization rules. + +Action responses expose capability booleans or the effective role needed to render controls: + +- View rows show Details only. +- Collaborate rows show Edit, Run now, and Pause/Resume. +- Owner rows additionally show Delete and sharing management. + +Run-history reads become access-aware by resource id. Compatibility inputs using name/scope may remain where existing integrations require them, but they resolve to a resource before authorization and never infer permission from caller-selected scope. + +### Acquisition remains unchanged + +Automatic schedule acquisition, event acquisition, and manual run acquisition remain separate. Sharing changes who can discover or request an operation; it does not change when triggers fire or which identity executes them. + +- schedules continue to scan due `jobs/*.md` resources; +- events continue to match registered events, conditions, and creator-owned event metadata; +- run-now continues through the durable run queue and shared background runner; +- every accepted run resolves and revalidates the immutable creator identity. + +## UI Design + +### Unified list + +Remove the Personal and Organization section headers, duplicated loading states, and section-specific create buttons. Keep one page-level **New automation** action and one list sorted consistently by the existing product rule selected during implementation. + +Each row contains: + +- name, trigger, enabled/paused state, last status, schedule/event summary, instruction preview, and next/last run; +- one access label from the row-level visibility rules; +- a specific-user count and compact avatars for owned Specific people rows; +- only the actions permitted by the server-returned effective role. + +List errors are inline and must distinguish an unreadable list from a valid empty list. + +### Editor sharing section + +The Sharing section appears in both create and edit: + +- radio/card choices for Personal, Organization, and Specific people; +- Organization explains that all members receive View only; +- Specific people uses an account picker, not a free-form invite field; +- each selected account has a View/Collaborate role picker; +- outside-organization accounts have a clear label; +- selecting Collaborate for any outside-organization account reveals the required acknowledgement; +- no Public option, copy-link tab, or unauthenticated access copy is shown. + +Only the owner can edit the Sharing section. A collaborator opening Edit sees definition fields only, with sharing either summarized read-only or omitted from the editable controls. + +### Optimistic behavior and errors + +List mutations are optimistic: + +- pause/resume updates the row immediately; +- edits update the cached row on success and retain the prior row for rollback; +- sharing updates immediately refresh the row's access badge, avatars, and capabilities; +- delete removes an owner row optimistically only after destructive confirmation. + +Every optimistic mutation stores an exact prior cache snapshot and restores it on error. The active dialog or row shows the server error inline. A failed save does not close the editor, clear selected users, or display a success-like empty state. + +## Security and Privacy + +- Public sharing is intentionally excluded in both UI and server validation. +- The server verifies that every specific-user target is an existing account. +- Account search returns only the minimum fields needed by the picker and requires authentication. +- Outside-organization Collaborate is rejected without explicit acknowledgement. +- Organization membership grants View only and is checked against actual membership, not merely the caller's active organization selection. +- Collaborators cannot delete, change grants, change visibility, replace `createdBy`, change `runAs`, or retarget run history. +- Run-now authorization is evaluated for the caller, then execution identity is independently resolved from the resource creator. +- Revoked users cannot retain access through stale name/scope inputs. +- Share and definition writes use parameterized, dialect-portable SQL and transactions. +- Notifications, if added later, are a separate product decision; this design does not require invitation email delivery. + +## Localization and Documentation + +All new visible copy belongs in the core English message catalog and the existing supported locale catalogs. Placeholders and plural/count variants must stay aligned, and the account picker and acknowledgement must be RTL-safe. + +Update the canonical automations skill and automation documentation to describe: + +- the unified list; +- Personal, Organization, and Specific people; +- View and Collaborate permissions; +- outside-organization acknowledgement; +- no public links; +- creator-bound execution; +- legacy organization-visible compatibility. + +Matching localized automation docs must be updated when the English source meaning changes. + +## Acceptance Criteria + +1. One list shows owned, organization-visible, and specifically shared automations without Personal/Organization sections. +2. Each row has the correct Personal, Organization, Shared with you role, or owned specific-user summary. +3. View users can list and inspect details/status/history but cannot mutate. +4. Collaborate users can edit, pause/resume, and run now but cannot delete or manage sharing. +5. Owners can perform all operations, including delete and sharing management. +6. Organization visibility grants View to current members only. +7. Specific-user search supports any existing account and labels outside-organization accounts. +8. Outside-organization Collaborate cannot be saved without explicit acknowledgement. +9. Public visibility and public links are unavailable and rejected server-side. +10. Definitions remain in `jobs/*.md`; sharing is stored in a resource-id overlay. +11. Automatic, event, and manual acquisition behavior is unchanged. +12. All runs, including collaborator-requested run-now, execute as the immutable creator. +13. Legacy organization-owned jobs remain organization-visible without destructive migration. +14. `manage-automations` and direct UI actions return and enforce identical access decisions. +15. Definition and sharing writes are atomic; failed optimistic updates roll back and surface inline errors. diff --git a/docs/plans/2026-08-05-automation-sharing-implementation.md b/docs/plans/2026-08-05-automation-sharing-implementation.md new file mode 100644 index 0000000000..787e3af36e --- /dev/null +++ b/docs/plans/2026-08-05-automation-sharing-implementation.md @@ -0,0 +1,601 @@ +# Automation Sharing Implementation Plan + +> **For the Fusion agent:** Execute this plan task-by-task in order. Re-read every target file immediately before editing, preserve concurrent work, and complete each verification checkpoint before moving to the next task. Do not report the feature complete until the focused tests, workspace checks, and authenticated browser QA all pass on the final code. + +## Goal + +Ship row-level sharing for resource-backed automations while preserving `jobs/*.md` as the definition source of truth and preserving creator-bound execution. Replace the Personal/Organization UI sections with one access-aware Automations list. Owners can choose Personal, Organization (all current organization members View), or Specific people; specific users receive View or Collaborate. Collaborators may edit, pause/resume, and run now, while only owners may delete or manage sharing. Public sharing is intentionally excluded. + +## Architecture + +Add a job-specific SQL sharing overlay keyed by `resources.id`; do not migrate definitions out of `jobs/*.md` and do not reuse the generic resource `visibility` column, whose current meaning is workspace versus agent scratch. A core automation access service combines resource ownership, parsed immutable creator metadata, overlay visibility, current organization membership, and explicit user grants into one effective role: `owner`, `collaborate`, or `view`. + +All reads and mutations—`manage-automations`, direct Agent-page actions, run history, pause/resume, run-now, delete, and sharing replacement—must call that same access service. Create/edit submits a complete sharing state and commits the definition plus overlay/grants atomically. Legacy organization-owned jobs without an overlay are interpreted as organization-visible compatibility rows until the owner writes explicit sharing; there is no destructive backfill. + +The UI consumes one unified list action, renders role-derived capabilities, and uses optimistic cache updates with exact rollback snapshots and inline errors. The create/edit dialog owns only transient draft state; the existing `/agent#jobs` navigation and application-state contract remain unchanged. Schedule, event, and manual acquisition continue to use the existing scheduler, dispatcher, durable run queue, and background runner. Authorization may allow a collaborator to request run-now, but execution identity is still resolved from immutable `createdBy`/`runAs: creator` metadata. + +## Tech Stack + +- TypeScript +- React 19 and TanStack Query +- Agent Native `defineAction`, action discovery, and request context +- Core `resources` store and `getDbExec()` portable SQL abstraction +- SQLite, Postgres, and D1-compatible additive DDL +- Zod validation +- shadcn/toolkit Dialog, Picker, Avatar, Button, and form primitives +- Tabler Icons +- i18next through `useT()` and the core default message catalog +- Vitest and React DOM test utilities +- pnpm workspace scripts, oxfmt, package guards, and authenticated browser QA + +## 1. Establish the baseline and freeze the existing execution contract + +**Files to inspect:** + +- `packages/core/src/resources/store.ts` +- `packages/core/src/automations/service.ts` +- `packages/core/src/jobs/run-now.ts` +- `packages/core/src/jobs/background-automation-runner.ts` +- `packages/core/src/jobs/scheduler.ts` +- `packages/core/src/triggers/dispatcher.ts` +- `packages/core/src/triggers/actions.ts` +- `packages/core/src/jobs/actions/list-recurring-jobs.ts` +- `packages/core/src/jobs/actions/manage-recurring-job.ts` +- `packages/core/src/jobs/actions/run-automation-now.ts` +- `packages/core/src/jobs/actions/list-automation-runs.ts` + +**Steps:** + +1. Read the current branch versions again and record the exact current signatures before editing; do not assume this plan supersedes concurrent changes. +2. Confirm the current immutable execution path: + - new explicit automations persist `createdBy` and `runAs: creator`; + - scheduled and event paths resolve the creator before execution; + - run-now creates a durable `automation_runs` row and converges on the same background runner. +3. Confirm the legacy cases that must remain compatible: personal owner rows, encoded organization owners, and `__shared__` resources. +4. Run the current focused tests before implementation so later failures can be attributed to the change. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/automations/service.spec.ts \ + src/jobs/actions/actions.spec.ts \ + src/jobs/background-automation-runner.spec.ts \ + src/jobs/scheduler.spec.ts \ + src/triggers/actions.spec.ts \ + src/triggers/dispatcher.spec.ts +``` + +**Expected result:** all existing automation service, action, scheduler, dispatcher, and runner tests pass without code changes. Any pre-existing failure is documented and resolved before implementation proceeds rather than being hidden by the new work. + +## 2. Add dialect-portable job sharing storage + +**Files:** + +- Create `packages/core/src/automations/sharing-store.ts` +- Create `packages/core/src/automations/sharing-store.spec.ts` +- Modify `packages/core/src/resources/store.ts` +- Modify `packages/core/src/resources/index.ts` +- Modify `packages/core/src/db/client.ts` only if the current `DbExec` transaction contract cannot support the required transaction-scoped resource operations + +**Steps:** + +1. Define explicit domain types in `sharing-store.ts`: + - visibility: `private | organization`; + - grant role: `view | collaborate`; + - complete sharing input for Personal, Organization, or Specific people; + - stored overlay/grant rows and a normalized sharing summary. +2. Add idempotent startup DDL for two core-owned tables: + - one overlay row keyed by job resource id, with visibility and organization id; + - user grants keyed uniquely by resource id plus normalized user email, with View/Collaborate role. +3. Add portable indexes for organization-visible listing and user-grant listing. Use `ensureTableExists`/`ensureIndexExists` on Postgres and the existing retry/idempotency pattern on SQLite/D1. Do not add destructive DDL, provider-only SQL, or `drizzle-kit push`. +4. Add batched store operations to load overlays and grants for many resource ids in bounded queries. Do not build an N+1 API. +5. Add a transaction-scoped “replace complete sharing state” operation that deletes obsolete grants and inserts/updates the desired overlay/grants as one unit. +6. Extend the resource store with a transaction-scoped put/delete seam that accepts the existing `DbExec` abstraction and preserves resource ids. Queue resource change/delete notifications until the transaction has committed so polling cannot observe an event for a rolled-back write. +7. Keep the existing `ResourceVisibility = workspace | agent_scratch` unchanged. The job overlay must not overload it. +8. Test initialization, normalization, uniqueness, batched reads, complete replacement, rollback, and portability-sensitive SQL behavior. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/automations/sharing-store.spec.ts \ + src/resources/store.effective-context.spec.ts \ + src/resources/store.learnings-seed.spec.ts +pnpm guard:additive-migrations +``` + +**Expected result:** the overlay initializes idempotently; complete replacement either fully commits or leaves the old state intact; resource store regressions pass; the additive-migration guard reports no destructive schema change. + +## 3. Build the single automation access service and unified listing + +**Files:** + +- Create `packages/core/src/automations/access.ts` +- Create `packages/core/src/automations/access.spec.ts` +- Modify `packages/core/src/automations/service.ts` +- Modify `packages/core/src/automations/service.spec.ts` +- Modify `packages/core/src/resources/store.ts` +- Modify `packages/core/src/jobs/frontmatter.ts` only if a shared typed classifier/result is needed by both explicit and legacy definitions + +**Steps:** + +1. Implement one access resolver that accepts the caller identity and stable resource id, loads the `jobs/*.md` resource, parses its classification/frontmatter, and returns either no access or: + - effective role `owner | collaborate | view`; + - immutable creator and owning organization; + - explicit or legacy effective visibility; + - a sharing summary suitable for the list row; + - capability flags derived centrally from the role. +2. Derive ownership without changing execution metadata: + - personal resource owner email is the owner; + - organization resource `createdBy` is the owner; + - malformed or mismatched creator metadata fails closed for mutations. +3. Implement legacy compatibility without writing data: + - organization-owned rows with no overlay are organization-visible View to current members; + - the immutable creator remains owner; + - personal rows with no overlay remain private; + - retain the current `__shared__` compatibility behavior without broadening its run identity. +4. Implement unified listing for all accessible `jobs/*.md` resources. Batch resource candidates, overlays, grants, current membership, and profile labels. Avoid one full SQL round trip per item and avoid loading run history on the list path. +5. Return both explicit automations and legacy recurring jobs in one typed list shape, preserving their classification so the UI can keep the correct editor. +6. Replace `canUpdate` as the authorization source with `effectiveRole` and explicit capabilities (`canEdit`, `canOperate`, `canDelete`, `canManageSharing`). Compatibility adapters may map these to old fields only at their external boundary. +7. Add tests for owner, org View member, explicit View, explicit Collaborate, revoked grant, removed member, outside-org grant, malformed creator, duplicate names under different owners, inaccessible-id non-disclosure, and no-overlay legacy behavior. +8. Add a query-count assertion or store mock assertion proving the list batches access data instead of issuing one overlay/grant query per resource. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/automations/access.spec.ts \ + src/automations/service.spec.ts +``` + +**Expected result:** one list contains exactly the caller's owned, organization-visible, and explicitly shared rows; each row has the correct role/capabilities/sharing summary; legacy organization rows are visible without an overlay write; unauthorized or unreadable states fail closed. + +## 4. Make definition and sharing writes atomic and role-aware + +**Files:** + +- Modify `packages/core/src/automations/service.ts` +- Modify `packages/core/src/automations/service.spec.ts` +- Modify `packages/core/src/jobs/run-history.ts` +- Modify `packages/core/src/jobs/run-history.spec.ts` +- Modify `packages/core/src/jobs/run-now.ts` +- Create `packages/core/src/jobs/run-now.spec.ts` +- Modify `packages/core/src/jobs/tools.ts` +- Modify `packages/core/src/jobs/tools.spec.ts` + +**Steps:** + +1. Extend create and owner edit service inputs with a complete sharing state. Validate all fields before starting a transaction: + - Organization requires a current owning organization; + - Specific people requires at least one unique existing account; + - user emails are normalized; + - only View/Collaborate are accepted; + - no public value is representable or accepted; + - outside-organization Collaborate requires an acknowledgement for this exact request. +2. Query the canonical auth `user` table to prove each selected account exists. Determine outside-organization status from actual membership in the owning organization, not the target's active organization. +3. Commit resource content and the complete sharing overlay/grant state in one transaction. Preserve `createdBy`, `runAs`, resource owner, and resource id during edits. +4. Permit definition edits and enable/disable changes for Collaborate, but reject sharing changes from Collaborate. +5. Require Owner for delete. Delete the resource, its overlay/grants, and its run history atomically so name reuse cannot inherit old state. +6. Evolve run-now to resolve by stable resource id and require Collaborate. Retain a name/scope compatibility adapter only for existing callers; resolve it to a resource before checking access. +7. Ensure a collaborator-requested run stores the original resource owner/history key and that the worker still calls the current creator identity resolver. Never put the collaborator into `createdBy`, `runAs`, run owner, event owner, or background request context. +8. Update legacy recurring-job mutation helpers to call the same access boundary: Collaborate may edit/pause/resume/run; Owner alone may delete. +9. Add transaction rollback tests that force the definition write and sharing write to fail independently and assert neither partial state survives. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/automations/service.spec.ts \ + src/jobs/run-now.spec.ts \ + src/jobs/run-history.spec.ts \ + src/jobs/tools.spec.ts +``` + +**Expected result:** View cannot mutate; Collaborate can edit/pause/resume/run-now but cannot delete or share; Owner can do all operations; failed create/edit/delete leaves no partial definition, grant, overlay, or history state; collaborator run-now remains creator-bound. + +## 5. Unify and secure the direct Agent-page actions + +**Files:** + +- Modify `packages/core/src/triggers/actions/list-automations.ts` +- Modify `packages/core/src/triggers/actions/manage-automation.ts` +- Create `packages/core/src/triggers/actions/search-automation-share-users.ts` +- Create `packages/core/src/triggers/actions/get-automation-sharing.ts` only if the unified list response cannot safely supply the editor's current grant details +- Modify `packages/core/src/jobs/actions/list-recurring-jobs.ts` +- Modify `packages/core/src/jobs/actions/manage-recurring-job.ts` +- Modify `packages/core/src/jobs/actions/run-automation-now.ts` +- Modify `packages/core/src/jobs/actions/list-automation-runs.ts` +- Modify `packages/core/src/jobs/actions/actions.spec.ts` +- Modify `packages/core/src/triggers/actions/actions.spec.ts` +- Modify `packages/core/src/server/action-discovery.ts` +- Modify `packages/core/src/server/action-discovery.spec.ts` +- Modify `packages/core/src/vite/action-types-plugin.ts` + +**Steps:** + +1. Change `list-automations` into the one frontend read for all accessible explicit and legacy definitions; remove its required Personal/Organization scope split. +2. Return stable resource id, classification, effective role, capability flags, and row sharing summary. Return complete grants only to the owner and only where the editor needs them. +3. Make `manage-automation` resource-id-first and include complete sharing state on create/owner edit. Keep compatibility fields only where existing callers still need them. +4. Update recurring-job actions to delegate to the same service/access boundary rather than retaining creator/org-admin authorization in parallel. +5. Make run-history and run-now resource-id-aware and access-controlled. View can read history; Collaborate can run-now. +6. Add a bounded, authenticated user-search action for the Specific people picker. It returns only existing-account fields needed by the UI plus `outsideOrganization`; it does not create invites, expose private account data, or accept an arbitrary organization id from the client as authority. +7. Register the new UI-only action(s) in action discovery and the Vite action type registry. Preserve `agentTool: false`; the conversational surface remains `manage-automations`. +8. Keep frontend actions under `defineAction` with Zod schemas and typed thrown failures. Do not add a REST twin route. +9. Update discovery and action tests to prove the new actions are registered, authenticated, frontend-only, and do not overwrite template actions. +10. Keep `list-recurring-jobs` available as a compatibility action if external UI consumers require it, but stop using it from `AgentJobsTab` after Task 7. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/jobs/actions/actions.spec.ts \ + src/triggers/actions/actions.spec.ts \ + src/server/action-discovery.spec.ts +pnpm guard:no-action-twin-routes +``` + +**Expected result:** the frontend has one unified list action; all mutations enforce effective role server-side; search returns bounded existing-account results with outside-org labels; action discovery includes the new UI actions as non-agent tools; no duplicate API route is introduced. + +## 6. Bring `manage-automations` to full agent parity + +**Files:** + +- Modify `packages/core/src/triggers/actions.ts` +- Modify `packages/core/src/triggers/actions.spec.ts` +- Modify `packages/core/src/agent/production-agent.ts` only if the native action schema registry requires a typed surface update + +**Steps:** + +1. Make `action=list` call the same unified access-aware service as the UI and return effective role and sharing summary for every accessible automation. +2. Add sharing inputs to define/update using a deliberate, documented shape for Personal, Organization, or Specific people. Do not expose `public` in the schema. +3. Add or extend an owner-only sharing operation if a complete sharing replacement cannot be expressed cleanly through update; keep it inside the single `manage-automations` tool rather than adding another agent tool. +4. Require the explicit outside-organization Collaborate acknowledgement in the agent call just as in the UI. The tool description must tell the agent to explain the consequence and receive user confirmation before setting it. +5. Route update, pause/resume, run-now, delete, and sharing through the same access service and stable resource id resolution used by direct actions. +6. Teach the tool description that View is read-only, Collaborate permits edit/pause/resume/run-now, only Owner can delete/manage sharing, public links are excluded, and every run still uses the creator's identity. +7. Preserve plan-mode read/write classification and existing confirmation requirements for create, delete, and real run-now side effects. +8. Avoid success-shaped error coercion. Service failures must remain distinguishable from valid empty lists and completed writes. +9. Add agent parity tests for owner, View, Collaborate, outside-org acknowledgement, public-value rejection, legacy organization visibility, and creator-bound run-now. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run src/triggers/actions.spec.ts +pnpm guard:no-error-string-returns +``` + +**Expected result:** the agent sees and can operate exactly the rows/capabilities the UI sees; public sharing is absent/rejected; outside-org Collaborate requires acknowledgement; errors are not returned as plausible success; the existing plan-mode contract still passes. + +## 7. Replace split-scope hooks with one unified optimistic cache + +**Files:** + +- Modify `packages/core/src/client/agent-page/use-jobs.ts` +- Create `packages/core/src/client/agent-page/use-jobs.spec.tsx` if hook-level rollback behavior is not adequately covered by `AgentJobsTab.spec.tsx` + +**Steps:** + +1. Replace `useAutomations(scope)` plus separate personal/org cache keys with one `useAutomations()` query using the unified action. +2. Update the row type to include classification, stable resource id, effective role, capabilities, and sharing summary. +3. Make manage, pause/resume, run-now, delete, and sharing mutations resource-id-first. +4. Keep exact optimistic snapshots for every mutation and restore the prior cache on error. Do not use an empty array or dropped row as a failure fallback. +5. On successful create/edit/share/delete, reconcile or invalidate only the unified list and the affected run-history/sharing query. Remove invalidation of obsolete personal/org list keys. +6. Represent create drafts with their selected sharing label and capabilities so optimistic rows do not briefly claim the wrong access. +7. Keep run history lazy and keyed by resource id. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/client/agent-page/use-jobs.spec.tsx \ + --passWithNoTests +``` + +**Expected result:** the hook uses one list cache; successful mutations update the correct row; forced failures restore the exact previous row/list; no stale Personal/Organization query key remains in the Agent-page hook. + +## 8. Add the Sharing editor and existing-account picker + +**Files:** + +- Create `packages/core/src/client/agent-page/AutomationSharingFields.tsx` +- Create `packages/core/src/client/agent-page/AutomationSharingFields.spec.tsx` +- Modify `packages/core/src/client/agent-page/AutomationEditorDialog.tsx` +- Modify `packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx` +- Reuse patterns from `packages/core/src/client/sharing/ShareDialog.tsx` +- Reuse patterns from `packages/core/src/client/sharing/useShareButtonController.ts` +- Reuse `packages/core/src/client/sharing/share-controller-helpers.ts` only where its cache helpers fit; do not expose generic Public/Admin behavior to automation sharing + +**Steps:** + +1. Add a Sharing section to create and owner edit with mutually exclusive Personal, Organization, and Specific people controls. +2. Organization copy must state that every current organization member receives View only. +3. Build Specific people with toolkit/shadcn Picker and Avatar primitives backed by the authenticated user-search action. Do not accept a free-form nonexistent email. +4. Mark each result outside the owning organization. Preserve the marker on selected users. +5. Provide only View and Collaborate roles; do not expose generic sharing's Admin role. +6. When any outside-org account is set to Collaborate, show a required acknowledgement describing edit, pause/resume, run-now, and creator-bound execution. Bind acknowledgement validity to the current selected users/roles so later changes require acknowledgement again. +7. Hide or render sharing read-only for Collaborate editors. Only owners can submit a sharing replacement. +8. Submit one complete sharing state with the definition instead of firing sequential share/unshare mutations. +9. Keep selected users, roles, acknowledgement state, and definition fields when a save fails. Show the server error inline and keep the dialog open. +10. Do not render Public, copy-link, or anonymous-access controls. Do not modify the generic ShareDialog for this product-specific mode unless a reusable no-public/no-admin API is first proven to preserve its existing consumers. +11. Ensure logical-direction spacing and labels work in RTL. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/client/agent-page/AutomationSharingFields.spec.tsx \ + src/client/agent-page/AutomationEditorDialog.spec.tsx +``` + +**Expected result:** all three modes submit the correct complete state; Specific people supports View/Collaborate existing accounts; outside-org Collaborate cannot submit before acknowledgement; Public is absent; collaborator edit cannot change sharing; service errors preserve the draft and remain inline. + +## 9. Convert `AgentJobsTab` to one role-aware list + +**Files:** + +- Modify `packages/core/src/client/agent-page/AgentJobsTab.tsx` +- Modify `packages/core/src/client/agent-page/AgentJobsTab.spec.tsx` +- Modify `packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx` +- Modify `packages/core/src/client/agent-page/AutomationDetailsDialog.tsx` +- Create `packages/core/src/client/agent-page/AutomationDetailsDialog.spec.tsx` if access/status coverage cannot remain clear in the tab spec + +**Steps:** + +1. Remove Personal and Organization sections, section descriptions, duplicated loading/error states, and the organization section create button. +2. Render one stable unified list and retain the existing page-level New automation button. +3. Add row sharing presentation: + - owned private: Personal; + - organization-visible: Organization; + - grantee: Shared with you · View/Collaborate; + - owned Specific people: grantee count plus compact avatars/initials. +4. Use the server-returned capabilities rather than `canUpdate` or `canManageOrg` to render actions: + - View: Details only; + - Collaborate: Details, Edit, Run now, Pause/Resume; + - Owner: all Collaborate actions plus Delete and sharing management through Edit. +5. Keep details, status, last/next run, blocked reason, and run-history behavior available to View. +6. Route every row operation by stable resource id. Delete remains behind the existing destructive confirmation and Owner authorization. +7. Surface row/dialog errors inline. Ensure an optimistic failure restores row state and does not close an editor or confirmation prematurely. +8. Keep `/agent#jobs` and Agent-page navigation unchanged; no new persistent application-state key is needed. +9. Update tests to cover a mixed list of owner Personal, owner Organization, owner Specific people, shared View, shared Collaborate, and legacy organization-visible rows. Assert forbidden controls are absent, not merely disabled. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/client/agent-page/AgentJobsTab.spec.tsx \ + src/client/agent-page/AgentJobsTab.blocked.spec.tsx \ + src/client/agent-page/AutomationDetailsDialog.spec.tsx \ + --passWithNoTests +``` + +**Expected result:** there is exactly one Automations list and one loading/error boundary; every access label is correct; each role sees only its permitted actions; View can inspect details/status/history; blocked-run messaging remains truthful. + +## 10. Update localization, canonical guidance, and product docs + +**Files:** + +- Modify `packages/core/src/localization/default-messages.ts` +- Modify `packages/core/src/client/i18n-key-coverage.spec.ts` only if new plural or dynamic-key coverage requires a test extension +- Modify `.agents/skills/automations/SKILL.md` +- Modify `packages/core/docs/content/automations.mdx` +- Modify `packages/core/docs/content/recurring-jobs.mdx` if legacy compatibility wording changes +- Modify all matching localized automation docs: + - `packages/core/docs/content/locales/ar-SA/automations.mdx` + - `packages/core/docs/content/locales/de-DE/automations.mdx` + - `packages/core/docs/content/locales/es-ES/automations.mdx` + - `packages/core/docs/content/locales/fr-FR/automations.mdx` + - `packages/core/docs/content/locales/hi-IN/automations.mdx` + - `packages/core/docs/content/locales/ja-JP/automations.mdx` + - `packages/core/docs/content/locales/ko-KR/automations.mdx` + - `packages/core/docs/content/locales/pt-BR/automations.mdx` + - `packages/core/docs/content/locales/zh-CN/automations.mdx` + - `packages/core/docs/content/locales/zh-TW/automations.mdx` + +**Steps:** + +1. Read the `writing-agent-instructions` skill immediately before editing the canonical automations skill. +2. Replace split-scope UI copy with unified-list, Personal/Organization/Specific people, View/Collaborate, outside-organization, acknowledgement, count/plural, and inline-error copy in the English core catalog. +3. Keep action names, role enum values, resource ids, and route fragments unlocalized. +4. Use plural keys for specific-user counts and preserve placeholders across translations. +5. Update the canonical automations skill to teach access roles, owner-only delete/sharing, no public links, outside-org acknowledgement, resource-id overlay, legacy compatibility, and creator-bound execution. +6. Update English automation docs and every existing localized automation doc when the meaning changes. If a locale cannot be translated in this task, stop and explicitly list it rather than silently shipping an English-only semantic change. +7. Update recurring-jobs docs only where needed to explain that legacy jobs participate in the unified list and compatibility visibility without definition migration. +8. Do not document a REST API or a public link flow. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run src/client/i18n-key-coverage.spec.ts +pnpm guard:i18n-catalogs +pnpm guard:workspace-skills +``` + +**Expected result:** every literal UI key exists in the English catalog; plural/placeholders are valid; workspace skill synchronization passes; English and all ten localized automation docs describe the same product contract. + +## 11. Complete compatibility and security regression coverage + +**Files:** + +- Modify `packages/core/src/automations/access.spec.ts` +- Modify `packages/core/src/automations/service.spec.ts` +- Modify `packages/core/src/automations/sharing-store.spec.ts` +- Modify `packages/core/src/jobs/actions/actions.spec.ts` +- Modify `packages/core/src/jobs/run-now.spec.ts` +- Modify `packages/core/src/jobs/scheduler.spec.ts` +- Modify `packages/core/src/jobs/background-automation-runner.spec.ts` +- Modify `packages/core/src/triggers/actions.spec.ts` +- Modify `packages/core/src/triggers/dispatcher.spec.ts` +- Modify `packages/core/src/server/action-discovery.spec.ts` +- Modify `packages/core/src/client/agent-page/AgentJobsTab.spec.tsx` +- Modify `packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx` +- Modify `packages/core/src/client/agent-page/AutomationSharingFields.spec.tsx` + +**Steps:** + +1. Add a permission matrix test covering every operation for View, Collaborate, and Owner. +2. Add public-sharing rejection at schema, service, agent-tool, and direct-action boundaries. +3. Add account-validation tests for nonexistent users, normalized email duplicates, current org member, outside-org View, outside-org Collaborate without acknowledgement, and acknowledged outside-org Collaborate. +4. Add revocation tests: grant removal, organization membership removal, owner deletion, and unreadable membership/account lookup all fail closed on the next operation. +5. Add immutable-identity tests proving collaborator edits and run-now never change creator, resource owner, event owner, execution org, run-history owner, or `runAs`. +6. Add compatibility tests for no-overlay personal rows, no-overlay organization rows, current `__shared__` rows, explicit automations, and legacy recurring jobs. +7. Add atomicity tests for resource write failure, overlay write failure, grant write failure, history cleanup failure, and duplicate-name races. +8. Add UI rollback tests for pause/resume, edit, sharing, and delete failures with inline errors. +9. Add discovery tests proving all direct actions remain auth-protected and non-agent tools while `manage-automations` remains the agent surface. +10. Re-run scheduler/dispatcher regression tests to prove automatic acquisition did not start consulting the collaborator identity or sharing role. + +**Verification checkpoint:** + +```bash +pnpm --filter @agent-native/core exec vitest --run \ + src/automations/access.spec.ts \ + src/automations/service.spec.ts \ + src/automations/sharing-store.spec.ts \ + src/jobs/actions/actions.spec.ts \ + src/jobs/run-now.spec.ts \ + src/jobs/scheduler.spec.ts \ + src/jobs/background-automation-runner.spec.ts \ + src/triggers/actions.spec.ts \ + src/triggers/dispatcher.spec.ts \ + src/server/action-discovery.spec.ts \ + src/client/agent-page/AgentJobsTab.spec.tsx \ + src/client/agent-page/AutomationEditorDialog.spec.tsx \ + src/client/agent-page/AutomationSharingFields.spec.tsx +``` + +**Expected result:** the complete permission, compatibility, atomicity, revocation, UI rollback, and creator-identity matrix passes with no skipped security case. + +## 12. Add the package changeset and run focused package checks + +**Files:** + +- Create `.changeset/automation-sharing.md` +- All modified TypeScript, TSX, MDX, and Markdown files from Tasks 2–11 + +**Steps:** + +1. Add a minor changeset for `@agent-native/core` describing the user-facing unified automation sharing capability. Do not manually change the package version. +2. Run oxfmt on modified source files. Review formatting changes and ensure no unrelated files are touched. +3. Run the full core test selection relevant to the feature and package typecheck/build. +4. Inspect the final diff for secret literals, private data, raw colors, public-link language, debug logging, accidental source-generated artifacts, and manual migration/destructive SQL. + +**Verification checkpoint:** + +```bash +pnpm exec oxfmt --write \ + packages/core/src/automations \ + packages/core/src/jobs \ + packages/core/src/triggers \ + packages/core/src/client/agent-page \ + packages/core/src/localization/default-messages.ts \ + packages/core/src/server/action-discovery.ts \ + packages/core/src/vite/action-types-plugin.ts +pnpm --filter @agent-native/core typecheck +pnpm --filter @agent-native/core build +pnpm --filter @agent-native/core test +pnpm changeset:status +``` + +**Expected result:** formatting is clean; core typecheck, build, and tests pass; changeset status reports the pending `@agent-native/core` minor entry; no package version was edited manually. + +## 13. Run i18n, security, workspace, and full preparation guards + +**Files:** + +- No new files; fix only failures caused by this implementation in the files already listed above + +**Steps:** + +1. Run the targeted guards first for fast feedback. +2. Run the repository preparation command on the final code. It includes formatting, workspace typecheck, fast tests, and all guards. +3. If a check fails, fix the actual boundary. Do not add an opt-out pragma unless the repository's documented exception genuinely applies and the reason is reviewer-visible. +4. Re-run the failed targeted check and then `pnpm prep` until the exact final code passes. + +**Verification checkpoint:** + +```bash +pnpm guard:additive-migrations +pnpm guard:no-silent-coercion +pnpm guard:no-error-string-returns +pnpm guard:no-unscoped-queries +pnpm guard:no-secret-literals +pnpm guard:no-raw-colors +pnpm guard:i18n-catalogs +pnpm guard:workspace-skills +pnpm prep +``` + +**Expected result:** every targeted guard and the complete preparation workflow pass on the final implementation. No failure is converted into an empty list, false success, or stale optimistic state. + +## 14. Perform authenticated browser QA with multiple existing accounts + +**Files to validate in the running app:** + +- `packages/core/src/client/agent-page/AgentJobsTab.tsx` +- `packages/core/src/client/agent-page/AutomationEditorDialog.tsx` +- `packages/core/src/client/agent-page/AutomationSharingFields.tsx` +- `packages/core/src/client/agent-page/AutomationDetailsDialog.tsx` + +**Steps:** + +1. Start the workspace using the repository's configured development command: + +```bash +pnpm dev +``` + +2. Use an existing organization and existing test accounts. Do not create a new organization, change an account's active organization, or move credentials between organizations merely to set up QA. +3. Open an authenticated app's `/agent#jobs` as the owner and verify: + - one list and no Personal/Organization sections; + - new Personal automation shows Personal; + - Organization shows Organization and states View for members; + - Specific people picker finds existing in-org and outside-org accounts; + - outside-org labels are visible; + - outside-org Collaborate blocks save until acknowledgement; + - no Public or copy-link option exists; + - a failed save leaves the dialog and draft intact with an inline error. +4. Sign in as a View recipient and verify the shared row says `Shared with you · View`; Details/status/run history are available; Edit, Pause/Resume, Run now, Delete, and sharing controls are absent. +5. Sign in as a Collaborate recipient and verify the row says `Shared with you · Collaborate`; Edit, Pause/Resume, and Run now work; Delete and sharing controls are absent. +6. As Collaborate, run now and inspect the durable run/details plus server logs to prove execution used the original creator identity, not the collaborator. Confirm the next scheduled run is unchanged. +7. As the owner, revoke the explicit grant and verify the recipient loses the row after sync/refetch. Remove or simulate removal of organization membership only through an existing approved test fixture, then verify organization visibility disappears. +8. Open a legacy organization-owned job with no overlay and verify it appears as Organization without any migration prompt or definition rewrite. +9. Force or safely simulate a mutation failure and verify pause/edit/sharing optimistic state rolls back exactly and the error is inline. +10. Inspect browser console and network for unexpected errors or failed 4xx/5xx requests during every role flow. Expected authorization rejections used by negative tests must show the intended typed error and no partial write. +11. Stop the dev process after QA using the environment's normal server controls; do not add server commands or credentials to source/docs. + +**Verification checkpoint:** + +Capture a concise QA record containing: + +- tested app URL/path and build identifier; +- owner, in-org View, explicit View, and explicit Collaborate scenarios using redacted/synthetic test-account labels; +- before/after access labels and visible controls; +- outside-org acknowledgement rejection then success; +- creator identity observed for collaborator run-now; +- legacy compatibility observation; +- optimistic rollback observation; +- console/network result. + +**Expected result:** all role and sharing flows match the approved design in the real authenticated UI; creator-bound execution is proven end-to-end; no public flow is reachable; no unexpected console or network error remains. + +## Final Completion Checklist + +- [ ] One access-aware Automations list replaces Personal/Organization sections. +- [ ] Row labels and owned specific-user count/avatars are correct. +- [ ] Personal, Organization, and Specific people are mutually exclusive and atomic. +- [ ] View and Collaborate permissions are enforced server-side and reflected in UI. +- [ ] Only Owner can delete or manage sharing. +- [ ] Any existing account can be selected; outside-org accounts are labeled. +- [ ] Outside-org Collaborate requires explicit acknowledgement. +- [ ] Public links and public visibility are absent and rejected. +- [ ] `jobs/*.md` remains the definition source of truth. +- [ ] Sharing overlay is additive, resource-id keyed, indexed, and dialect-portable. +- [ ] Legacy organization-owned jobs remain visible without destructive migration. +- [ ] Agent and UI call the same access/write services. +- [ ] Definition/sharing/delete writes are atomic and fail loudly. +- [ ] Optimistic updates have exact rollback and inline errors. +- [ ] Schedule, event, and manual acquisition are unchanged. +- [ ] Every run remains bound to the immutable creator. +- [ ] Canonical skill, English docs, all localized automation docs, and i18n catalog are updated. +- [ ] `@agent-native/core` changeset is present without a manual version bump. +- [ ] Focused tests, package checks, i18n/workspace/security guards, and `pnpm prep` pass. +- [ ] Authenticated multi-account browser QA passes with clean console/network output. diff --git a/packages/core/package.json b/packages/core/package.json index 5e39e57e01..fb8babec94 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -188,6 +188,14 @@ "./localization/actions/set-localization-preference": "./dist/localization/actions/set-localization-preference.js", "./credentials": "./dist/credentials/index.js", "./event-bus": "./dist/event-bus/index.js", + "./jobs/actions/list-recurring-jobs": "./dist/jobs/actions/list-recurring-jobs.js", + "./jobs/actions/manage-recurring-job": "./dist/jobs/actions/manage-recurring-job.js", + "./jobs/actions/run-automation-now": "./dist/jobs/actions/run-automation-now.js", + "./jobs/actions/list-automation-runs": "./dist/jobs/actions/list-automation-runs.js", + "./triggers/actions/list-automations": "./dist/triggers/actions/list-automations.js", + "./triggers/actions/list-automation-events": "./dist/triggers/actions/list-automation-events.js", + "./triggers/actions/manage-automation": "./dist/triggers/actions/manage-automation.js", + "./triggers/actions/search-automation-accounts": "./dist/triggers/actions/search-automation-accounts.js", "./fetch-tool": "./dist/extensions/fetch-tool.js", "./extensions/url-safety": "./dist/extensions/url-safety.js", "./tools/url-safety": "./dist/extensions/url-safety.js", diff --git a/packages/core/src/automations/access.spec.ts b/packages/core/src/automations/access.spec.ts new file mode 100644 index 0000000000..7767ca98e9 --- /dev/null +++ b/packages/core/src/automations/access.spec.ts @@ -0,0 +1,677 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + loadGrants: vi.fn(), + loadOverlays: vi.fn(), + resourceGet: vi.fn(), + resourceListAllOwners: vi.fn(), +})); + +vi.mock("../db/client.js", () => ({ + getDbExec: () => ({ execute: mocks.execute }), +})); + +vi.mock("../resources/store.js", () => ({ + SHARED_OWNER: "__shared__", + organizationIdFromResourceOwner: (owner: string) => + owner.startsWith("__organization__:") + ? owner.slice("__organization__:".length) + : null, + resourceGet: mocks.resourceGet, + resourceListAllOwners: mocks.resourceListAllOwners, +})); + +vi.mock("./sharing-store.js", () => ({ + loadAutomationSharingGrants: mocks.loadGrants, + loadAutomationSharingOverlays: mocks.loadOverlays, +})); + +import { + listAccessibleAutomations, + resolveAutomationAccess, +} from "./access.js"; + +interface TestResourceOptions { + id: string; + name?: string; + owner: string; + createdBy?: string; + orgId?: string; + explicit?: boolean; +} + +function testResource(options: TestResourceOptions) { + const frontmatter = [ + "---", + 'schedule: "0 8 * * *"', + "enabled: true", + ...(options.explicit ? ["triggerType: schedule"] : []), + ...(options.createdBy ? [`createdBy: ${options.createdBy}`] : []), + ...(options.orgId ? [`orgId: ${options.orgId}`] : []), + ...(options.createdBy ? ["runAs: creator"] : []), + "---", + "", + "Do the work.", + ].join("\n"); + return { + id: options.id, + owner: options.owner, + path: `jobs/${options.name ?? options.id}.md`, + content: frontmatter, + mimeType: "text/markdown", + size: frontmatter.length, + createdAt: 1, + updatedAt: 1, + createdBy: "user" as const, + visibility: "workspace" as const, + threadId: null, + runId: null, + expiresAt: null, + metadata: null, + }; +} + +function overlay( + resourceId: string, + visibility: "private" | "organization", + organizationId: string | null, +) { + return { + resourceId, + visibility, + organizationId, + createdAt: 1, + updatedAt: 1, + }; +} + +function grant( + resourceId: string, + email: string, + role: "view" | "collaborate", +) { + return { + resourceId, + email, + role, + createdAt: 1, + updatedAt: 1, + }; +} + +let membershipRows: Array<{ org_id: string; email: string }>; +let profileRows: Array<{ + email: string; + name: string | null; + image?: string | null; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + membershipRows = []; + profileRows = []; + mocks.loadOverlays.mockResolvedValue(new Map()); + mocks.loadGrants.mockResolvedValue(new Map()); + mocks.resourceGet.mockResolvedValue(null); + mocks.resourceListAllOwners.mockResolvedValue([]); + mocks.execute.mockImplementation(async (statement: { sql: string }) => { + if (statement.sql.includes("FROM org_members")) { + return { rows: membershipRows }; + } + if (statement.sql.includes('FROM "user"')) { + return { rows: profileRows }; + } + throw new Error(`Unexpected query: ${statement.sql}`); + }); +}); + +describe("automation access", () => { + it("returns owners, organization viewers, and explicit view/collaborate grants with centralized capabilities", async () => { + const resources = [ + testResource({ + id: "owned", + owner: "alice@example.com", + createdBy: "alice@example.com", + explicit: true, + }), + testResource({ + id: "org", + owner: "__organization__:org-1", + createdBy: "creator@example.com", + orgId: "org-1", + explicit: true, + }), + testResource({ + id: "view", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }), + testResource({ + id: "collaborate", + owner: "outside-owner@example.com", + createdBy: "outside-owner@example.com", + explicit: true, + }), + ]; + mocks.resourceListAllOwners.mockResolvedValue(resources); + mocks.loadOverlays.mockResolvedValue( + new Map([ + ["owned", overlay("owned", "private", null)], + ["org", overlay("org", "organization", "org-1")], + ["view", overlay("view", "private", null)], + ["collaborate", overlay("collaborate", "private", null)], + ]), + ); + mocks.loadGrants.mockResolvedValue( + new Map([ + [ + "owned", + [ + grant("owned", " Viewer@Example.com ", "view"), + grant("owned", "missing@example.com", "collaborate"), + ], + ], + [ + "view", + [ + grant("view", "alice@example.com", "view"), + grant("view", "other-viewer@example.com", "view"), + ], + ], + [ + "collaborate", + [ + grant("collaborate", "alice@example.com", "collaborate"), + grant("collaborate", "other-editor@example.com", "collaborate"), + ], + ], + ]), + ); + membershipRows = [ + { org_id: "org-1", email: "alice@example.com" }, + { org_id: "org-1", email: "creator@example.com" }, + ]; + profileRows = [ + { email: "creator@example.com", name: "Creator Name", image: null }, + { + email: "viewer@example.com", + name: "Viewer Name", + image: "https://example.com/viewer.png", + }, + ]; + + const result = await listAccessibleAutomations({ + userEmail: "Alice@Example.com", + }); + + expect( + result.map(({ resource, effectiveRole }) => [resource.id, effectiveRole]), + ).toEqual([ + ["collaborate", "collaborate"], + ["org", "view"], + ["owned", "owner"], + ["view", "view"], + ]); + expect(result.find((entry) => entry.resource.id === "owned")).toMatchObject( + { + capabilities: { + canEdit: true, + canOperate: true, + canDelete: true, + canManageSharing: true, + }, + sharing: { + source: "explicit", + visibility: "private", + grants: [ + { + email: "viewer@example.com", + role: "view", + name: "Viewer Name", + avatar: "https://example.com/viewer.png", + }, + { + email: "missing@example.com", + role: "collaborate", + name: null, + avatar: null, + }, + ], + }, + classification: { kind: "automation" }, + }, + ); + expect(result.find((entry) => entry.resource.id === "org")).toMatchObject({ + capabilities: { + canEdit: false, + canOperate: false, + canDelete: false, + canManageSharing: false, + }, + creator: { label: "Creator Name" }, + }); + expect( + result.find((entry) => entry.resource.id === "collaborate")?.capabilities, + ).toMatchObject({ canEdit: true, canOperate: true, canDelete: false }); + expect( + result.find((entry) => entry.resource.id === "view")?.sharing, + ).toMatchObject({ grantCount: 2 }); + expect( + result.find((entry) => entry.resource.id === "view")?.sharing, + ).not.toHaveProperty("grants"); + expect( + result.find((entry) => entry.resource.id === "collaborate")?.sharing, + ).toMatchObject({ grantCount: 2 }); + expect( + result.find((entry) => entry.resource.id === "collaborate")?.sharing, + ).not.toHaveProperty("grants"); + }); + + it("allows personal resources to use organization and specific-sharing overlay context without changing their owner", async () => { + const organizationVisible = testResource({ + id: "personal-organization", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }); + const specificallyShared = testResource({ + id: "personal-specific", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }); + mocks.resourceListAllOwners.mockResolvedValue([ + organizationVisible, + specificallyShared, + ]); + mocks.loadOverlays.mockResolvedValue( + new Map([ + [ + "personal-organization", + overlay("personal-organization", "organization", "org-1"), + ], + ["personal-specific", overlay("personal-specific", "private", "org-1")], + ]), + ); + mocks.loadGrants.mockResolvedValue( + new Map([ + [ + "personal-specific", + [grant("personal-specific", "member@example.com", "collaborate")], + ], + ]), + ); + membershipRows = [{ org_id: "org-1", email: "member@example.com" }]; + + const memberResult = await listAccessibleAutomations({ + userEmail: "member@example.com", + }); + expect(memberResult).toEqual([ + expect.objectContaining({ + resource: expect.objectContaining({ id: "personal-organization" }), + effectiveRole: "view", + owningOrganizationId: null, + sharing: expect.objectContaining({ + visibility: "organization", + organizationId: "org-1", + }), + }), + expect.objectContaining({ + resource: expect.objectContaining({ id: "personal-specific" }), + effectiveRole: "collaborate", + owningOrganizationId: null, + sharing: expect.objectContaining({ + visibility: "private", + organizationId: "org-1", + }), + }), + ]); + + const ownerResult = await listAccessibleAutomations({ + userEmail: "owner@example.com", + }); + expect(ownerResult).toHaveLength(2); + expect(ownerResult.every((entry) => entry.effectiveRole === "owner")).toBe( + true, + ); + }); + + it("removes organization visibility with membership but preserves an explicit grant", async () => { + const organizationOnly = testResource({ + id: "organization-only", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }); + const explicitlyGranted = testResource({ + id: "explicitly-granted", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }); + mocks.resourceListAllOwners.mockResolvedValue([ + organizationOnly, + explicitlyGranted, + ]); + mocks.loadOverlays.mockResolvedValue( + new Map([ + [ + "organization-only", + overlay("organization-only", "organization", "org-1"), + ], + [ + "explicitly-granted", + overlay("explicitly-granted", "organization", "org-1"), + ], + ]), + ); + mocks.loadGrants.mockResolvedValue( + new Map([ + [ + "explicitly-granted", + [grant("explicitly-granted", "removed@example.com", "view")], + ], + ]), + ); + membershipRows = []; + + const result = await listAccessibleAutomations({ + userEmail: "removed@example.com", + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + resource: { id: "explicitly-granted" }, + effectiveRole: "view", + }); + }); + + it("removes access after grant revocation or organization membership removal", async () => { + const granted = testResource({ + id: "granted", + owner: "owner@example.com", + createdBy: "owner@example.com", + }); + const organization = testResource({ + id: "organization", + owner: "__organization__:org-1", + createdBy: "creator@example.com", + orgId: "org-1", + }); + mocks.resourceListAllOwners.mockResolvedValue([granted, organization]); + mocks.loadOverlays.mockResolvedValue( + new Map([ + ["granted", overlay("granted", "private", null)], + ["organization", overlay("organization", "organization", "org-1")], + ]), + ); + membershipRows = [{ org_id: "org-1", email: "creator@example.com" }]; + + await expect( + listAccessibleAutomations({ userEmail: "alice@example.com" }), + ).resolves.toEqual([]); + }); + + it("honors an outside-organization explicit grant without inventing membership", async () => { + const resource = testResource({ + id: "outside", + owner: "__organization__:org-1", + createdBy: "creator@example.com", + orgId: "org-1", + explicit: true, + }); + mocks.resourceListAllOwners.mockResolvedValue([resource]); + mocks.loadOverlays.mockResolvedValue( + new Map([["outside", overlay("outside", "private", "org-1")]]), + ); + mocks.loadGrants.mockResolvedValue( + new Map([ + ["outside", [grant("outside", "guest@example.com", "collaborate")]], + ]), + ); + membershipRows = [{ org_id: "org-1", email: "creator@example.com" }]; + + const [result] = await listAccessibleAutomations({ + userEmail: "guest@example.com", + }); + expect(result).toMatchObject({ + effectiveRole: "collaborate", + owningOrganizationId: "org-1", + }); + expect(membershipRows).not.toContainEqual( + expect.objectContaining({ email: "guest@example.com" }), + ); + }); + + it("fails closed for malformed or removed organization creators", async () => { + const missingCreator = testResource({ + id: "missing", + owner: "__organization__:org-1", + orgId: "org-1", + }); + const mismatchedOrg = testResource({ + id: "mismatch", + owner: "__organization__:org-1", + createdBy: "creator@example.com", + orgId: "org-2", + }); + const removedCreator = testResource({ + id: "removed", + owner: "__organization__:org-1", + createdBy: "removed@example.com", + orgId: "org-1", + }); + mocks.resourceListAllOwners.mockResolvedValue([ + missingCreator, + mismatchedOrg, + removedCreator, + ]); + mocks.loadOverlays.mockResolvedValue( + new Map([["removed", overlay("removed", "organization", "org-1")]]), + ); + membershipRows = [{ org_id: "org-1", email: "alice@example.com" }]; + + await expect( + listAccessibleAutomations({ userEmail: "alice@example.com" }), + ).resolves.toEqual([]); + }); + + it("keeps duplicate names under different owners as distinct stable-id rows", async () => { + const first = testResource({ + id: "first", + name: "digest", + owner: "first@example.com", + createdBy: "first@example.com", + }); + const second = testResource({ + id: "second", + name: "digest", + owner: "second@example.com", + createdBy: "second@example.com", + }); + mocks.resourceListAllOwners.mockResolvedValue([second, first]); + mocks.loadOverlays.mockResolvedValue( + new Map([ + ["first", overlay("first", "private", null)], + ["second", overlay("second", "private", null)], + ]), + ); + mocks.loadGrants.mockResolvedValue( + new Map([ + ["first", [grant("first", "alice@example.com", "view")]], + ["second", [grant("second", "alice@example.com", "view")]], + ]), + ); + + const result = await listAccessibleAutomations({ + userEmail: "alice@example.com", + }); + expect(result.map(({ name, resource }) => [name, resource.id])).toEqual([ + ["digest", "first"], + ["digest", "second"], + ]); + }); + + it("does not disclose whether an inaccessible resource id exists", async () => { + const inaccessible = testResource({ + id: "secret-id", + owner: "owner@example.com", + createdBy: "owner@example.com", + explicit: true, + }); + mocks.resourceGet + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(inaccessible); + + const missing = await resolveAutomationAccess( + { userEmail: "alice@example.com" }, + "secret-id", + ); + const denied = await resolveAutomationAccess( + { userEmail: "alice@example.com" }, + "secret-id", + ); + + expect(missing).toBeNull(); + expect(denied).toBeNull(); + }); + + it("preserves no-overlay legacy personal, organization, and __shared__ behavior", async () => { + const resources = [ + testResource({ + id: "personal", + owner: "alice@example.com", + createdBy: "alice@example.com", + }), + testResource({ + id: "other-personal", + owner: "other@example.com", + createdBy: "other@example.com", + }), + testResource({ + id: "legacy-org", + owner: "__organization__:org-1", + createdBy: "creator@example.com", + orgId: "org-1", + }), + testResource({ + id: "legacy-shared", + owner: "__shared__", + }), + ]; + mocks.resourceListAllOwners.mockResolvedValue(resources); + membershipRows = [ + { org_id: "org-1", email: "alice@example.com" }, + { org_id: "org-1", email: "creator@example.com" }, + ]; + + const result = await listAccessibleAutomations({ + userEmail: "alice@example.com", + }); + + expect( + result.map(({ resource, effectiveRole, sharing }) => ({ + id: resource.id, + role: effectiveRole, + source: sharing.source, + visibility: sharing.visibility, + })), + ).toEqual([ + { + id: "legacy-org", + role: "view", + source: "legacy", + visibility: "organization", + }, + { + id: "legacy-shared", + role: "view", + source: "legacy", + visibility: "shared", + }, + { + id: "personal", + role: "owner", + source: "legacy", + visibility: "private", + }, + ]); + expect( + result.find((entry) => entry.resource.id === "legacy-shared"), + ).toMatchObject({ + immutableCreator: null, + classification: { kind: "job" }, + capabilities: { canOperate: false }, + }); + }); + + it("batches overlays, grants, owner and overlay memberships, and profile labels for the list", async () => { + const organizationResources = Array.from({ length: 20 }, (_, index) => + testResource({ + id: `job-${index}`, + owner: "__organization__:org-1", + createdBy: `creator-${index}@example.com`, + orgId: "org-1", + }), + ); + const overlayOrganizationResource = testResource({ + id: "personal-overlay-org", + owner: "personal-owner@example.com", + createdBy: "personal-owner@example.com", + }); + const resources = [...organizationResources, overlayOrganizationResource]; + mocks.resourceListAllOwners.mockResolvedValue(resources); + mocks.loadOverlays.mockResolvedValue( + new Map([ + [ + "personal-overlay-org", + overlay("personal-overlay-org", "organization", "org-2"), + ], + ]), + ); + mocks.loadGrants.mockResolvedValue( + new Map( + organizationResources.map((resource, index) => [ + resource.id, + [grant(resource.id, `grant-${index}@example.com`, "view")], + ]), + ), + ); + membershipRows = [ + { org_id: "org-1", email: "alice@example.com" }, + ...organizationResources.map((_, index) => ({ + org_id: "org-1", + email: `creator-${index}@example.com`, + })), + { org_id: "org-2", email: "alice@example.com" }, + ]; + + await listAccessibleAutomations({ userEmail: "alice@example.com" }); + + expect(mocks.resourceListAllOwners).toHaveBeenCalledTimes(1); + expect(mocks.loadOverlays).toHaveBeenCalledTimes(1); + expect(mocks.loadGrants).toHaveBeenCalledTimes(1); + const membershipQueries = mocks.execute.mock.calls.filter(([statement]) => + statement.sql.includes("FROM org_members"), + ); + const profileQueries = mocks.execute.mock.calls.filter(([statement]) => + statement.sql.includes('FROM "user"'), + ); + expect(membershipQueries).toHaveLength(1); + expect(membershipQueries[0]?.[0].args).toEqual( + expect.arrayContaining(["org-1", "org-2"]), + ); + expect(profileQueries).toHaveLength(1); + expect(profileQueries[0]?.[0].args).toEqual( + expect.arrayContaining([ + "creator-0@example.com", + "creator-19@example.com", + "grant-0@example.com", + "grant-19@example.com", + "personal-owner@example.com", + ]), + ); + }); +}); diff --git a/packages/core/src/automations/access.ts b/packages/core/src/automations/access.ts new file mode 100644 index 0000000000..f4a63276f5 --- /dev/null +++ b/packages/core/src/automations/access.ts @@ -0,0 +1,488 @@ +import { getDbExec, type DbExec } from "../db/client.js"; +import { + parseJobResource, + type JobFrontmatter, + type JobResourceClassification, +} from "../jobs/frontmatter.js"; +import { + organizationIdFromResourceOwner, + resourceGet, + resourceListAllOwners, + SHARED_OWNER, + type Resource, +} from "../resources/store.js"; +import { + loadAutomationSharingGrants, + loadAutomationSharingOverlays, + type AutomationSharingGrantRole, + type AutomationSharingGrantRow, + type AutomationSharingOverlayRow, +} from "./sharing-store.js"; + +export type AutomationEffectiveRole = "owner" | "collaborate" | "view"; +export type AutomationAccessSource = "explicit" | "legacy"; +export type AutomationEffectiveVisibility = + | "private" + | "organization" + | "shared"; + +export interface AutomationCapabilities { + canEdit: boolean; + canOperate: boolean; + canDelete: boolean; + canManageSharing: boolean; +} + +export interface AutomationSharingGrantSummary { + email: string; + role: AutomationSharingGrantRole; + name: string | null; + avatar: string | null; +} + +export interface AutomationSharingListSummary { + source: AutomationAccessSource; + visibility: AutomationEffectiveVisibility; + organizationId: string | null; + grantCount: number; + grants?: AutomationSharingGrantSummary[]; +} + +export interface AutomationCreatorSummary { + email: string | null; + label: string | null; +} + +export interface AccessibleAutomation { + resource: Resource; + name: string; + classification: JobResourceClassification; + meta: JobFrontmatter; + body: string; + immutableCreator: string | null; + owningOrganizationId: string | null; + effectiveRole: AutomationEffectiveRole; + capabilities: AutomationCapabilities; + sharing: AutomationSharingListSummary; + creator: AutomationCreatorSummary; +} + +export interface AutomationAccessActor { + userEmail: string; +} + +interface ParsedCandidate { + resource: Resource; + name: string; + classification: JobResourceClassification; + meta: JobFrontmatter; + body: string; + immutableCreator: string | null; + owningOrganizationId: string | null; + sharedCompatibility: boolean; +} + +interface AutomationAccountProfile { + name: string | null; + avatar: string | null; +} + +interface AccessData { + overlays: Map; + grants: Map; + memberships: Set; + profiles: Map; +} + +const SQL_BATCH_SIZE = 200; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeEmail(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase() ?? ""; + return normalized && EMAIL_RE.test(normalized) ? normalized : null; +} + +function normalizeActor(actor: AutomationAccessActor): string { + const email = normalizeEmail(actor.userEmail); + if (!email) { + throw Object.assign(new Error("Not authenticated."), { statusCode: 401 }); + } + return email; +} + +function automationName(path: string): string { + return path.replace(/^jobs\//, "").replace(/\.md$/, ""); +} + +function isJobResource(resource: Resource): boolean { + return ( + resource.path.startsWith("jobs/") && + resource.path.endsWith(".md") && + !resource.path.endsWith(".keep") + ); +} + +function parseCandidate(resource: Resource): ParsedCandidate | null { + if (!isJobResource(resource)) return null; + const parsed = parseJobResource(resource.content); + const owningOrganizationId = organizationIdFromResourceOwner(resource.owner); + const sharedCompatibility = resource.owner === SHARED_OWNER; + + if (sharedCompatibility) { + return { + resource, + name: automationName(resource.path), + ...parsed, + immutableCreator: normalizeEmail(parsed.meta.createdBy), + owningOrganizationId: null, + sharedCompatibility: true, + }; + } + + if (owningOrganizationId) { + const immutableCreator = normalizeEmail(parsed.meta.createdBy); + if ( + !immutableCreator || + (parsed.meta.orgId !== undefined && + parsed.meta.orgId.trim() !== owningOrganizationId) + ) { + return null; + } + return { + resource, + name: automationName(resource.path), + ...parsed, + immutableCreator, + owningOrganizationId, + sharedCompatibility: false, + }; + } + + const owner = normalizeEmail(resource.owner); + const declaredCreator = + parsed.meta.createdBy === undefined + ? null + : normalizeEmail(parsed.meta.createdBy); + if ( + !owner || + (parsed.meta.createdBy !== undefined && declaredCreator !== owner) + ) { + return null; + } + return { + resource, + name: automationName(resource.path), + ...parsed, + immutableCreator: owner, + owningOrganizationId: null, + sharedCompatibility: false, + }; +} + +function chunks(values: readonly T[], size = SQL_BATCH_SIZE): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +function membershipKey(orgId: string, email: string): string { + return `${orgId}\u0000${email}`; +} + +async function loadMemberships( + candidates: readonly ParsedCandidate[], + overlays: ReadonlyMap, + callerEmail: string, + client: DbExec, +): Promise> { + const organizationIds = [ + ...new Set( + candidates + .flatMap((candidate) => [ + candidate.owningOrganizationId, + overlays.get(candidate.resource.id)?.organizationId?.trim() || null, + ]) + .filter((value): value is string => !!value), + ), + ]; + const creatorEmails = candidates + .map((candidate) => candidate.immutableCreator) + .filter((value): value is string => !!value); + const emails = [...new Set([callerEmail, ...creatorEmails])]; + const memberships = new Set(); + + for (const orgBatch of chunks(organizationIds)) { + for (const emailBatch of chunks(emails)) { + const result = await client.execute({ + sql: `SELECT org_id, email FROM org_members WHERE org_id IN (${orgBatch.map(() => "?").join(", ")}) AND LOWER(email) IN (${emailBatch.map(() => "?").join(", ")})`, + args: [...orgBatch, ...emailBatch], + }); + for (const row of result.rows) { + const orgId = String(row.org_id ?? "").trim(); + const email = normalizeEmail(String(row.email ?? "")); + if (orgId && email) memberships.add(membershipKey(orgId, email)); + } + } + } + return memberships; +} + +async function loadProfiles( + candidates: readonly ParsedCandidate[], + grants: ReadonlyMap, + client: DbExec, +): Promise> { + const emails = [ + ...new Set([ + ...candidates + .map((candidate) => candidate.immutableCreator) + .filter((value): value is string => !!value), + ...[...grants.values()].flatMap((entries) => + entries + .map((entry) => normalizeEmail(entry.email)) + .filter((value): value is string => !!value), + ), + ]), + ]; + const profiles = new Map(); + for (const emailBatch of chunks(emails)) { + const result = await client.execute({ + sql: `SELECT email, name, image FROM "user" WHERE LOWER(email) IN (${emailBatch.map(() => "?").join(", ")})`, + args: emailBatch, + }); + for (const row of result.rows) { + const email = normalizeEmail(String(row.email ?? "")); + if (!email) continue; + const name = String(row.name ?? "").trim(); + const avatar = String(row.image ?? "").trim(); + profiles.set(email, { + name: name || null, + avatar: avatar || null, + }); + } + } + return profiles; +} + +function capabilitiesForRole( + role: AutomationEffectiveRole, +): AutomationCapabilities { + return { + canEdit: role === "owner" || role === "collaborate", + canOperate: role === "owner" || role === "collaborate", + canDelete: role === "owner", + canManageSharing: role === "owner", + }; +} + +function explicitSharingSummary( + overlay: AutomationSharingOverlayRow, + grants: readonly AutomationSharingGrantRow[], +): AutomationSharingListSummary | null { + if (overlay.visibility === "organization") { + const organizationId = overlay.organizationId?.trim() || null; + if (!organizationId) return null; + return { + source: "explicit", + visibility: "organization", + organizationId, + grantCount: grants.length, + }; + } + return { + source: "explicit", + visibility: "private", + organizationId: overlay.organizationId?.trim() || null, + grantCount: grants.length, + }; +} + +function legacySharingSummary( + candidate: ParsedCandidate, +): AutomationSharingListSummary { + if (candidate.sharedCompatibility) { + return { + source: "legacy", + visibility: "shared", + organizationId: null, + grantCount: 0, + }; + } + if (candidate.owningOrganizationId) { + return { + source: "legacy", + visibility: "organization", + organizationId: candidate.owningOrganizationId, + grantCount: 0, + }; + } + return { + source: "legacy", + visibility: "private", + organizationId: null, + grantCount: 0, + }; +} + +function effectiveRole( + candidate: ParsedCandidate, + callerEmail: string, + sharing: AutomationSharingListSummary, + grants: readonly AutomationSharingGrantRow[], + memberships: ReadonlySet, +): AutomationEffectiveRole | null { + if ( + !candidate.sharedCompatibility && + candidate.immutableCreator === callerEmail + ) { + return "owner"; + } + const grant = grants.find((entry) => entry.email === callerEmail); + if (grant) return grant.role as AutomationSharingGrantRole; + if (candidate.sharedCompatibility && sharing.source === "legacy") + return "view"; + if ( + sharing.visibility === "organization" && + sharing.organizationId && + memberships.has(membershipKey(sharing.organizationId, callerEmail)) + ) { + return "view"; + } + return null; +} + +function evaluateCandidate( + candidate: ParsedCandidate, + callerEmail: string, + data: AccessData, +): AccessibleAutomation | null { + if ( + candidate.owningOrganizationId && + (!candidate.immutableCreator || + !data.memberships.has( + membershipKey( + candidate.owningOrganizationId, + candidate.immutableCreator, + ), + )) + ) { + return null; + } + + const overlay = data.overlays.get(candidate.resource.id); + const grants = data.grants.get(candidate.resource.id) ?? []; + const sharing = overlay + ? explicitSharingSummary(overlay, grants) + : legacySharingSummary(candidate); + if (!sharing) return null; + if ( + overlay && + candidate.owningOrganizationId && + candidate.owningOrganizationId !== sharing.organizationId && + (sharing.visibility === "organization" || sharing.organizationId !== null) + ) { + return null; + } + + const role = effectiveRole( + candidate, + callerEmail, + sharing, + grants, + data.memberships, + ); + if (!role) return null; + const visibleSharing = + role === "owner" && grants.length + ? { + ...sharing, + grants: grants.map(({ email, role: grantRole }) => { + const normalizedEmail = normalizeEmail(email); + if (!normalizedEmail) { + throw new Error( + "Stored automation sharing grant has invalid email.", + ); + } + const profile = data.profiles.get(normalizedEmail); + return { + email: normalizedEmail, + role: grantRole, + name: profile?.name ?? null, + avatar: profile?.avatar ?? null, + }; + }), + } + : sharing; + return { + resource: candidate.resource, + name: candidate.name, + classification: candidate.classification, + meta: candidate.meta, + body: candidate.body, + immutableCreator: candidate.immutableCreator, + owningOrganizationId: candidate.owningOrganizationId, + effectiveRole: role, + capabilities: capabilitiesForRole(role), + sharing: visibleSharing, + creator: { + email: candidate.immutableCreator, + label: candidate.immutableCreator + ? (data.profiles.get(candidate.immutableCreator)?.name ?? + candidate.immutableCreator) + : null, + }, + }; +} + +async function loadAccessData( + candidates: readonly ParsedCandidate[], + callerEmail: string, + client: DbExec, +): Promise { + const resourceIds = candidates.map((candidate) => candidate.resource.id); + const [overlays, grants] = await Promise.all([ + loadAutomationSharingOverlays(resourceIds, client), + loadAutomationSharingGrants(resourceIds, client), + ]); + const [memberships, profiles] = await Promise.all([ + loadMemberships(candidates, overlays, callerEmail, client), + loadProfiles(candidates, grants, client), + ]); + return { overlays, grants, memberships, profiles }; +} + +export async function listAccessibleAutomations( + actor: AutomationAccessActor, +): Promise { + const callerEmail = normalizeActor(actor); + const resources = await resourceListAllOwners("jobs/"); + const candidates = resources + .map(parseCandidate) + .filter((candidate): candidate is ParsedCandidate => !!candidate); + if (!candidates.length) return []; + const data = await loadAccessData(candidates, callerEmail, getDbExec()); + return candidates + .map((candidate) => evaluateCandidate(candidate, callerEmail, data)) + .filter((entry): entry is AccessibleAutomation => !!entry) + .sort( + (left, right) => + left.name.localeCompare(right.name) || + left.resource.owner.localeCompare(right.resource.owner) || + left.resource.id.localeCompare(right.resource.id), + ); +} + +export async function resolveAutomationAccess( + actor: AutomationAccessActor, + resourceId: string, +): Promise { + const callerEmail = normalizeActor(actor); + const resource = await resourceGet(resourceId.trim()); + if (!resource) return null; + const candidate = parseCandidate(resource); + if (!candidate) return null; + const data = await loadAccessData([candidate], callerEmail, getDbExec()); + return evaluateCandidate(candidate, callerEmail, data); +} diff --git a/packages/core/src/automations/service.spec.ts b/packages/core/src/automations/service.spec.ts index 9dc0eb8e9a..7cc6d4b014 100644 --- a/packages/core/src/automations/service.spec.ts +++ b/packages/core/src/automations/service.spec.ts @@ -1,14 +1,57 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const executeMock = vi.hoisted(() => vi.fn()); -const resourceDeleteMock = vi.hoisted(() => vi.fn()); +const atomicBatchMock = vi.hoisted(() => vi.fn()); +const dbRuntime = vi.hoisted(() => ({ dialect: "sqlite" })); +const transactionExecuteMock = vi.hoisted(() => vi.fn()); +const transactionMock = vi.hoisted(() => vi.fn()); +const resourceDeleteWithDbMock = vi.hoisted(() => vi.fn()); const resourceGetByPathMock = vi.hoisted(() => vi.fn()); const resourceListMock = vi.hoisted(() => vi.fn()); -const resourcePutMock = vi.hoisted(() => vi.fn()); +const resourcePutWithDbMock = vi.hoisted(() => vi.fn()); const getUserSettingMock = vi.hoisted(() => vi.fn()); +const listAccessibleAutomationsMock = vi.hoisted(() => vi.fn()); +const resolveAutomationAccessMock = vi.hoisted(() => vi.fn()); +const replaceSharingMock = vi.hoisted(() => vi.fn()); +const deleteSharingMock = vi.hoisted(() => vi.fn()); +const deleteRunsWithDbMock = vi.hoisted(() => vi.fn()); +const resourceNotificationMock = vi.hoisted(() => vi.fn()); + +vi.mock("./access.js", () => ({ + listAccessibleAutomations: listAccessibleAutomationsMock, + resolveAutomationAccess: resolveAutomationAccessMock, +})); + +vi.mock("./sharing-store.js", () => ({ + deleteAutomationSharingStateWithDb: deleteSharingMock, + ensureAutomationSharingTables: vi.fn(), + normalizeAutomationSharingEmail: (email: string) => + email.trim().toLowerCase(), + prepareAutomationSharingDelete: (resourceId: string, guard: unknown) => ({ + statements: [ + { sql: "sharing-delete-grants", args: [resourceId, guard] }, + { sql: "sharing-delete-overlay", args: [resourceId, guard] }, + ], + }), + prepareAutomationSharingReplacement: ( + resourceId: string, + sharing: unknown, + options: unknown, + ) => ({ + statements: [ + { sql: "sharing-replace", args: [resourceId, sharing, options] }, + ], + }), + replaceAutomationSharingStateWithDb: replaceSharingMock, +})); vi.mock("../db/client.js", () => ({ - getDbExec: () => ({ execute: executeMock }), + getDbExec: () => ({ + execute: executeMock, + transaction: transactionMock, + atomicBatch: atomicBatchMock, + }), + getDialect: () => dbRuntime.dialect, intType: () => "INTEGER", isPostgres: () => false, })); @@ -28,16 +71,51 @@ vi.mock("../resources/store.js", () => ({ ? owner.slice("__organization__:".length) : null, organizationResourceOwner: (orgId: string) => `__organization__:${orgId}`, - resourceDelete: resourceDeleteMock, + ensureResourceStoreReady: vi.fn(), + prepareResourceBatchAssertion: (condition: unknown) => ({ + statements: [ + { sql: "atomic-guard-create" }, + { sql: "atomic-guard-assert", args: [condition] }, + ], + cleanupStatement: { sql: "atomic-guard-delete" }, + }), + prepareResourceCreate: ({ id, owner, path, content }: any) => ({ + value: resource(content, owner, { id, path }), + statements: [{ sql: "resource-create", args: [id, owner, path, content] }], + notifyAfterCommit: resourceNotificationMock, + }), + prepareResourceDelete: (current: any) => ({ + value: true, + statements: [{ sql: "resource-delete", args: [current.id] }], + notifyAfterCommit: resourceNotificationMock, + }), + prepareResourceUpdate: ({ current, content }: any) => ({ + value: { ...current, content, updatedAt: current.updatedAt + 1 }, + statements: [{ sql: "resource-update", args: [current.id, content] }], + notifyAfterCommit: resourceNotificationMock, + }), + resourceDeleteWithDb: resourceDeleteWithDbMock, resourceGetByPath: resourceGetByPathMock, resourceList: resourceListMock, - resourcePut: resourcePutMock, + resourcePutWithDb: resourcePutWithDbMock, })); +vi.mock("../jobs/run-history.js", () => ({ + deleteAutomationRunsWithDb: deleteRunsWithDbMock, + ensureAutomationRunHistoryReady: vi.fn(), + prepareAutomationRunsDelete: ( + owner: string, + name: string, + guard: unknown, + ) => ({ sql: "history-delete", args: [owner, name, guard] }), +})); + +import { parseJobResource } from "../jobs/frontmatter.js"; import { automationMatchesEventOwner, defineAutomation, deleteAutomation, + listAccessibleAutomationDefinitions, listAutomationDefinitions, resolveAutomationExecutionIdentity, updateAutomation, @@ -46,11 +124,15 @@ import { const actor = { userEmail: "Alice@Example.com", orgId: "org-1" }; const orgOwner = "__organization__:org-1"; -function resource(content: string, owner = orgOwner) { +function resource( + content: string, + owner = orgOwner, + overrides: { id?: string; path?: string } = {}, +) { return { - id: "automation-1", + id: overrides.id ?? "automation-1", owner, - path: "jobs/notify.md", + path: overrides.path ?? "jobs/notify.md", content, mimeType: "text/markdown", size: content.length, @@ -65,6 +147,40 @@ function resource(content: string, owner = orgOwner) { }; } +function accessible( + automationResource: ReturnType, + role: "owner" | "collaborate" | "view" = "owner", +) { + const parsed = parseJobResource(automationResource.content); + const organization = automationResource.owner.startsWith("__organization__:"); + return { + resource: automationResource, + name: automationResource.path.replace(/^jobs\//, "").replace(/\.md$/, ""), + classification: parsed.classification, + meta: parsed.meta, + body: parsed.body, + immutableCreator: parsed.meta.createdBy ?? automationResource.owner, + owningOrganizationId: organization ? "org-1" : null, + effectiveRole: role, + capabilities: { + canEdit: role !== "view", + canOperate: role !== "view", + canDelete: role === "owner", + canManageSharing: role === "owner", + }, + sharing: { + source: "explicit" as const, + visibility: "private" as const, + organizationId: organization ? "org-1" : null, + grantCount: role === "owner" ? 0 : 1, + }, + creator: { + email: parsed.meta.createdBy ?? automationResource.owner, + label: parsed.meta.createdBy ?? automationResource.owner, + }, + }; +} + const eventAutomation = `--- schedule: "" enabled: true @@ -82,15 +198,102 @@ deliveryDestination: "channel-1" Send the notification.`; +const eventAutomationWithCondition = `--- +schedule: "" +enabled: true +triggerType: event +event: mail.received +condition: only for urgent messages +mode: agentic +createdBy: alice@example.com +orgId: "org-1" +runAs: creator +--- + +Send the notification.`; + +function batchResults(count: number, successIndex: number, affected = 1) { + return Array.from({ length: count }, (_, index) => ({ + rows: [], + rowsAffected: index === successIndex ? affected : 1, + })); +} + describe("automation domain service", () => { beforeEach(() => { vi.clearAllMocks(); + dbRuntime.dialect = "sqlite"; + atomicBatchMock.mockResolvedValue([]); executeMock.mockResolvedValue({ rows: [{ role: "member" }] }); - resourceDeleteMock.mockResolvedValue(true); + transactionExecuteMock.mockResolvedValue({ rows: [], rowsAffected: 1 }); + transactionMock.mockImplementation(async (work) => + work({ execute: transactionExecuteMock }), + ); + resourceDeleteWithDbMock.mockResolvedValue({ + value: true, + notifyAfterCommit: vi.fn(), + }); resourceGetByPathMock.mockResolvedValue(null); resourceListMock.mockResolvedValue([]); - resourcePutMock.mockResolvedValue(undefined); + resourcePutWithDbMock.mockImplementation( + async (_tx, owner: string, path: string, content: string) => ({ + value: resource(content, owner), + notifyAfterCommit: vi.fn(), + }), + ); + replaceSharingMock.mockResolvedValue(undefined); + deleteSharingMock.mockResolvedValue(undefined); + deleteRunsWithDbMock.mockResolvedValue(undefined); getUserSettingMock.mockResolvedValue(null); + listAccessibleAutomationsMock.mockResolvedValue([]); + }); + + it("maps the centralized access result into the unified service list", async () => { + listAccessibleAutomationsMock.mockResolvedValue([ + { + resource: resource(eventAutomation), + name: "notify", + classification: { + kind: "automation", + hasExplicitTriggerType: true, + triggerType: "event", + }, + meta: { triggerType: "event" }, + body: "Send the notification.", + immutableCreator: "alice@example.com", + owningOrganizationId: "org-1", + effectiveRole: "collaborate", + capabilities: { + canEdit: true, + canOperate: true, + canDelete: false, + canManageSharing: false, + }, + sharing: { + source: "explicit", + visibility: "private", + organizationId: "org-1", + grantCount: 1, + }, + creator: { + email: "alice@example.com", + label: "Alice", + }, + }, + ]); + + const result = await listAccessibleAutomationDefinitions(actor); + + expect(listAccessibleAutomationsMock).toHaveBeenCalledWith({ + userEmail: "alice@example.com", + orgId: "org-1", + }); + expect(result[0]).toMatchObject({ + scope: "organization", + effectiveRole: "collaborate", + canUpdate: true, + capabilities: { canManageSharing: false }, + }); }); it("schedules a new automation in the timezone the creator saved", async () => { @@ -98,7 +301,7 @@ describe("automation domain service", () => { resourceGetByPathMock .mockResolvedValueOnce(null) .mockImplementation(async (owner: string) => - resource(resourcePutMock.mock.calls.at(-1)?.[2] as string, owner), + resource(resourcePutWithDbMock.mock.calls.at(-1)?.[3] as string, owner), ); const definition = await defineAutomation(actor, { @@ -117,11 +320,43 @@ describe("automation domain service", () => { ); }); + it("creates a manual automation without automatic trigger fields", async () => { + resourceGetByPathMock.mockResolvedValueOnce(null); + + const definition = await defineAutomation(actor, { + name: "on-demand-report", + scope: "organization", + triggerType: "manual", + body: "Build the report.", + schedule: "0 8 * * *", + timezone: "Not/A-Timezone", + event: "mail.received", + condition: "only for urgent messages", + }); + + expect(definition.meta).toMatchObject({ + schedule: "", + enabled: true, + triggerType: "manual", + createdBy: "alice@example.com", + orgId: "org-1", + runAs: "creator", + }); + expect(definition.meta).not.toHaveProperty("timezone"); + expect(definition.meta).not.toHaveProperty("event"); + expect(definition.meta).not.toHaveProperty("condition"); + expect(definition.meta).not.toHaveProperty("nextRun"); + + const content = resourcePutWithDbMock.mock.calls[0]?.[3] as string; + expect(content).toContain("triggerType: manual"); + expect(content).not.toMatch(/^(event|condition|nextRun|timezone):/m); + }); + it("creates an organization event automation owned by the org but run as its creator", async () => { resourceGetByPathMock .mockResolvedValueOnce(null) .mockImplementation(async (owner: string, path: string) => - resource(resourcePutMock.mock.calls.at(-1)?.[2] as string, owner), + resource(resourcePutWithDbMock.mock.calls.at(-1)?.[3] as string, owner), ); const definition = await defineAutomation(actor, { @@ -135,7 +370,8 @@ describe("automation domain service", () => { delivery: { platform: "slack", destination: "channel-1" }, }); - expect(resourcePutMock).toHaveBeenCalledWith( + expect(resourcePutWithDbMock).toHaveBeenCalledWith( + expect.objectContaining({ execute: transactionExecuteMock }), orgOwner, "jobs/notify.md", expect.stringMatching( @@ -166,7 +402,277 @@ describe("automation domain service", () => { body: "Send the notification.", }), ).rejects.toMatchObject({ statusCode: 403 }); - expect(resourcePutMock).not.toHaveBeenCalled(); + expect(resourcePutWithDbMock).not.toHaveBeenCalled(); + }); + + it("normalizes unique existing accounts and requires acknowledgement for outside collaborators", async () => { + executeMock.mockImplementation(async ({ sql }: { sql: string }) => { + if (sql.includes('FROM "user"')) { + return { + rows: [ + { email: "viewer@example.com" }, + { email: "outside@example.com" }, + ], + }; + } + if (sql.includes("LOWER(email) IN")) { + return { rows: [{ email: "viewer@example.com" }] }; + } + return { rows: [{ role: "member" }] }; + }); + + await expect( + defineAutomation(actor, { + name: "shared-digest", + scope: "organization", + triggerType: "manual", + body: "Build it.", + sharing: { + kind: "specific", + grants: [ + { email: " Viewer@Example.com ", role: "view" }, + { email: "OUTSIDE@example.com", role: "collaborate" }, + ], + }, + }), + ).rejects.toThrow(/Acknowledge outside-organization collaborators/); + expect(transactionMock).not.toHaveBeenCalled(); + + await defineAutomation(actor, { + name: "shared-digest", + scope: "organization", + triggerType: "manual", + body: "Build it.", + sharing: { + kind: "specific", + grants: [ + { email: " Viewer@Example.com ", role: "view" }, + { email: "OUTSIDE@example.com", role: "collaborate" }, + ], + }, + acknowledgeExternalCollaborators: true, + }); + expect(replaceSharingMock).toHaveBeenCalledWith( + expect.anything(), + "automation-1", + { + kind: "specific", + organizationId: "org-1", + grants: [ + { email: "viewer@example.com", role: "view" }, + { email: "outside@example.com", role: "collaborate" }, + ], + }, + ); + }); + + it("rejects duplicate, nonexistent, and public sharing before writes", async () => { + executeMock.mockImplementation(async ({ sql }: { sql: string }) => + sql.includes('FROM "user"') + ? { rows: [] } + : { rows: [{ role: "member" }] }, + ); + const base = { + name: "invalid-sharing", + scope: "organization" as const, + triggerType: "manual" as const, + body: "Build it.", + }; + + await expect( + defineAutomation(actor, { + ...base, + sharing: { + kind: "specific", + grants: [ + { email: "same@example.com", role: "view" }, + { email: " SAME@example.com ", role: "collaborate" }, + ], + }, + }), + ).rejects.toThrow(/unique/); + await expect( + defineAutomation(actor, { + ...base, + sharing: { + kind: "specific", + grants: [{ email: "missing@example.com", role: "view" }], + }, + }), + ).rejects.toThrow(/do not exist/); + await expect( + defineAutomation(actor, { + ...base, + sharing: { kind: "public" } as never, + }), + ).rejects.toThrow(/Unsupported automation sharing state/); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("rejects Collaborate changing complete sharing state", async () => { + const automationResource = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "collaborate"), + ); + + await expect( + updateAutomation( + { userEmail: "collaborator@example.com", orgId: "org-1" }, + { + resourceId: "automation-1", + sharing: { kind: "personal" }, + }, + ), + ).rejects.toThrow(/Only the automation owner can change sharing/); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("does not notify or replace sharing when either atomic write fails", async () => { + resourcePutWithDbMock.mockRejectedValueOnce(new Error("definition failed")); + await expect( + defineAutomation(actor, { + name: "rollback-definition", + scope: "organization", + triggerType: "manual", + body: "Build it.", + }), + ).rejects.toThrow("definition failed"); + expect(replaceSharingMock).not.toHaveBeenCalled(); + + const notifyAfterCommit = vi.fn(); + resourcePutWithDbMock.mockResolvedValueOnce({ + value: resource(eventAutomation), + notifyAfterCommit, + }); + replaceSharingMock.mockRejectedValueOnce(new Error("sharing failed")); + await expect( + defineAutomation(actor, { + name: "rollback-sharing", + scope: "organization", + triggerType: "manual", + body: "Build it.", + }), + ).rejects.toThrow("sharing failed"); + expect(notifyAfterCommit).not.toHaveBeenCalled(); + }); + + describe("D1 atomic mutations", () => { + beforeEach(() => { + dbRuntime.dialect = "d1"; + }); + + it("creates the resource and complete sharing state in one atomic batch", async () => { + atomicBatchMock.mockResolvedValue(batchResults(5, 2)); + + await defineAutomation(actor, { + name: "d1-create", + scope: "organization", + triggerType: "manual", + body: "Build it.", + }); + + expect(transactionMock).not.toHaveBeenCalled(); + expect(atomicBatchMock).toHaveBeenCalledTimes(1); + expect(atomicBatchMock.mock.calls[0]?.[0].slice(2, -1)).toEqual([ + expect.objectContaining({ sql: "resource-create" }), + expect.objectContaining({ sql: "sharing-replace" }), + ]); + expect(resourceNotificationMock).toHaveBeenCalledTimes(1); + }); + + it("updates content and sharing in one guarded atomic batch", async () => { + const current = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(current, "owner"), + ); + atomicBatchMock.mockResolvedValue(batchResults(5, 2)); + + await updateAutomation(actor, { + resourceId: current.id, + enabled: false, + sharing: { kind: "personal" }, + }); + + expect(atomicBatchMock.mock.calls[0]?.[0].slice(2, -1)).toEqual([ + expect.objectContaining({ sql: "resource-update" }), + expect.objectContaining({ + sql: "sharing-replace", + args: expect.arrayContaining([ + current.id, + expect.objectContaining({ kind: "personal" }), + expect.objectContaining({ guard: expect.anything() }), + ]), + }), + ]); + expect(resourceNotificationMock).toHaveBeenCalledTimes(1); + }); + + it("deletes sharing, history, and the guarded resource in one batch", async () => { + const current = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(current, "owner"), + ); + atomicBatchMock.mockResolvedValue(batchResults(7, 5)); + + await deleteAutomation(actor, { resourceId: current.id }); + + expect(atomicBatchMock.mock.calls[0]?.[0].slice(2, -1)).toEqual([ + expect.objectContaining({ sql: "sharing-delete-grants" }), + expect.objectContaining({ sql: "sharing-delete-overlay" }), + expect.objectContaining({ sql: "history-delete" }), + expect.objectContaining({ sql: "resource-delete" }), + ]); + expect(resourceNotificationMock).toHaveBeenCalledTimes(1); + }); + + it("leaves notification pending when create or update batches fail", async () => { + atomicBatchMock.mockRejectedValueOnce(new Error("D1 batch failed")); + await expect( + defineAutomation(actor, { + name: "d1-failure", + scope: "organization", + triggerType: "manual", + body: "Build it.", + }), + ).rejects.toThrow("D1 batch failed"); + expect(resourceNotificationMock).not.toHaveBeenCalled(); + + const current = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(current, "owner"), + ); + atomicBatchMock.mockRejectedValueOnce(new Error("sharing insert failed")); + await expect( + updateAutomation(actor, { + resourceId: current.id, + enabled: false, + sharing: { kind: "personal" }, + }), + ).rejects.toThrow("sharing insert failed"); + expect(resourceNotificationMock).not.toHaveBeenCalled(); + }); + + it("reports conditional create and delete conflicts without notifying", async () => { + atomicBatchMock.mockResolvedValueOnce(batchResults(5, 2, 0)); + await expect( + defineAutomation(actor, { + name: "d1-conflict", + scope: "organization", + triggerType: "manual", + body: "Build it.", + }), + ).rejects.toMatchObject({ statusCode: 409 }); + + const current = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(current, "owner"), + ); + atomicBatchMock.mockResolvedValueOnce(batchResults(7, 5, 0)); + await expect( + deleteAutomation(actor, { resourceId: current.id }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(resourceNotificationMock).not.toHaveBeenCalled(); + }); }); it("lists org automations for members and computes creator/admin mutation rights", async () => { @@ -196,15 +702,21 @@ describe("automation domain service", () => { expect(memberItems[0]?.canUpdate).toBe(false); }); - it("lets an org admin update or delete without retargeting the creator", async () => { - executeMock.mockResolvedValue({ rows: [{ role: "admin" }] }); - resourceGetByPathMock.mockResolvedValue(resource(eventAutomation)); + it("lets Collaborate edit without retargeting identity but reserves delete for Owner", async () => { + const automationResource = resource(eventAutomation); + resourceGetByPathMock.mockResolvedValue(automationResource); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "collaborate"), + ); + transactionExecuteMock.mockResolvedValue({ + rows: [{ id: "automation-1" }], + rowsAffected: 1, + }); const updated = await updateAutomation( - { userEmail: "admin@example.com", orgId: "org-1" }, + { userEmail: "collaborator@example.com", orgId: "org-1" }, { - name: "notify", - scope: "organization", + resourceId: "automation-1", enabled: false, model: "claude-opus", mcpTools: ["mcp__mail__read", "mcp__mail__send"], @@ -218,35 +730,131 @@ describe("automation domain service", () => { model: "claude-opus", mcpTools: ["mcp__mail__read", "mcp__mail__send"], }); - expect(resourcePutMock).toHaveBeenCalledWith( - orgOwner, - "jobs/notify.md", - expect.stringContaining("createdBy: alice@example.com"), + expect(transactionExecuteMock).toHaveBeenCalledWith( + expect.objectContaining({ + sql: "resource-update", + args: [ + "automation-1", + expect.stringContaining("createdBy: alice@example.com"), + ], + }), ); - await deleteAutomation( - { userEmail: "admin@example.com", orgId: "org-1" }, - "organization", + await expect( + deleteAutomation( + { userEmail: "collaborator@example.com", orgId: "org-1" }, + { resourceId: "automation-1" }, + ), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(transactionExecuteMock).not.toHaveBeenCalledWith( + expect.objectContaining({ sql: "resource-delete" }), + ); + + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "owner"), + ); + await deleteAutomation(actor, { resourceId: "automation-1" }); + expect(transactionExecuteMock).toHaveBeenCalledWith( + expect.objectContaining({ + sql: "resource-delete", + args: ["automation-1"], + }), + ); + expect(deleteSharingMock).toHaveBeenCalled(); + expect(deleteRunsWithDbMock).toHaveBeenCalledWith( + expect.anything(), + orgOwner, "notify", ); - expect(resourceDeleteMock).toHaveBeenCalledWith("automation-1"); }); - it("rejects an ordinary org member mutating another creator's automation", async () => { - executeMock.mockResolvedValue({ rows: [{ role: "member" }] }); - resourceGetByPathMock.mockResolvedValue(resource(eventAutomation)); + it("rejects a stale non-D1 update as a 409 conflict without writing sharing", async () => { + const automationResource = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "owner"), + ); + transactionExecuteMock.mockImplementation(async (statement: any) => { + if (statement?.sql === "resource-update") { + return { rows: [], rowsAffected: 0 }; + } + return { rows: [{ id: "automation-1" }], rowsAffected: 1 }; + }); + + await expect( + updateAutomation(actor, { resourceId: "automation-1", enabled: false }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(replaceSharingMock).not.toHaveBeenCalled(); + expect(resourceNotificationMock).not.toHaveBeenCalled(); + }); + + it("rejects a stale non-D1 delete as a 409 conflict without deleting sharing or history", async () => { + const automationResource = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "owner"), + ); + transactionExecuteMock.mockResolvedValue({ rows: [], rowsAffected: 0 }); + + await expect( + deleteAutomation(actor, { resourceId: "automation-1" }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(deleteSharingMock).not.toHaveBeenCalled(); + expect(deleteRunsWithDbMock).not.toHaveBeenCalled(); + expect(resourceNotificationMock).not.toHaveBeenCalled(); + }); + + it("clears a leftover event condition when an automation switches to schedule", async () => { + const automationResource = resource(eventAutomationWithCondition); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "owner"), + ); + transactionExecuteMock.mockResolvedValue({ + rows: [{ id: "automation-1" }], + rowsAffected: 1, + }); + + const updated = await updateAutomation(actor, { + resourceId: "automation-1", + triggerType: "schedule", + schedule: "0 8 * * *", + }); + + expect(updated.meta.triggerType).toBe("schedule"); + expect(updated.meta.condition).toBeUndefined(); + }); + + it("rejects an explicit condition for a schedule automation", async () => { + const automationResource = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "owner"), + ); + + await expect( + updateAutomation(actor, { + resourceId: "automation-1", + triggerType: "schedule", + schedule: "0 8 * * *", + condition: "only when urgent", + }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("rejects View mutating another creator's automation", async () => { + const automationResource = resource(eventAutomation); + resolveAutomationAccessMock.mockResolvedValue( + accessible(automationResource, "view"), + ); await expect( updateAutomation( { userEmail: "member@example.com", orgId: "org-1" }, { - name: "notify", - scope: "organization", + resourceId: "automation-1", enabled: false, }, ), ).rejects.toMatchObject({ statusCode: 403 }); - expect(resourcePutMock).not.toHaveBeenCalled(); + expect(resourcePutWithDbMock).not.toHaveBeenCalled(); }); it("revalidates creator existence and membership for execution and scopes events to the creator", async () => { diff --git a/packages/core/src/automations/service.ts b/packages/core/src/automations/service.ts index 932cf03696..3a07541824 100644 --- a/packages/core/src/automations/service.ts +++ b/packages/core/src/automations/service.ts @@ -1,4 +1,9 @@ -import { getDbExec } from "../db/client.js"; +import { + getDbExec, + getDialect, + type DbExec, + type DbExecStatement, +} from "../db/client.js"; import { isValidCron, isValidTimezone, nextOccurrence } from "../jobs/cron.js"; import { buildJobResourceContent, @@ -6,17 +11,40 @@ import { parseJobResource, type JobFrontmatter, } from "../jobs/frontmatter.js"; -import { deleteAutomationRuns } from "../jobs/run-history.js"; +import { + deleteAutomationRunsWithDb, + ensureAutomationRunHistoryReady, + prepareAutomationRunsDelete, +} from "../jobs/run-history.js"; import { resolveUserSchedulingTimezone } from "../localization/user-timezone.js"; import { organizationIdFromResourceOwner, organizationResourceOwner, - resourceDelete, + ensureResourceStoreReady, + prepareResourceBatchAssertion, + prepareResourceCreate, + prepareResourceDelete, + prepareResourceUpdate, resourceGetByPath, resourceList, - resourcePut, + resourcePutWithDb, type Resource, + type TransactionScopedResourceWrite, } from "../resources/store.js"; +import { + listAccessibleAutomations, + resolveAutomationAccess, + type AccessibleAutomation, +} from "./access.js"; +import { + deleteAutomationSharingStateWithDb, + ensureAutomationSharingTables, + prepareAutomationSharingDelete, + prepareAutomationSharingReplacement, + normalizeAutomationSharingEmail, + replaceAutomationSharingStateWithDb, + type CompleteAutomationSharingState, +} from "./sharing-store.js"; export type AutomationScope = "personal" | "organization"; @@ -30,13 +58,19 @@ export interface AutomationDefinition { name: string; scope: AutomationScope; meta: JobFrontmatter & { - triggerType: "schedule" | "event"; + triggerType: "schedule" | "event" | "manual"; mode: "agentic" | "deterministic"; }; body: string; canUpdate: boolean; } +export interface AccessibleAutomationDefinition extends AccessibleAutomation { + scope: AutomationScope; + /** Compatibility alias for callers not yet migrated to capabilities.canEdit. */ + canUpdate: boolean; +} + export interface AutomationDelivery { originScopeId?: string; platform?: string; @@ -48,8 +82,9 @@ export interface AutomationDelivery { export interface DefineAutomationInput { name: string; scope: AutomationScope; - triggerType: "schedule" | "event"; + triggerType: "schedule" | "event" | "manual"; body: string; + enabled?: boolean; schedule?: string; timezone?: string; event?: string; @@ -59,21 +94,30 @@ export interface DefineAutomationInput { model?: string; mcpTools?: unknown; delivery?: AutomationDelivery; + sharing?: CompleteAutomationSharingState; + acknowledgeExternalCollaborators?: boolean; } -export type DefinedAutomation = Omit; +export type DefinedAutomation = Omit & { + resourceId: string; +}; export interface UpdateAutomationInput { - name: string; - scope: AutomationScope; + resourceId?: string; + name?: string; + scope?: AutomationScope; + triggerType?: "schedule" | "event" | "manual"; enabled?: boolean; body?: string; + event?: string; condition?: string | null; delegatedPolicyId?: string | null; schedule?: string; timezone?: string; model?: string | null; mcpTools?: unknown; + sharing?: CompleteAutomationSharingState; + acknowledgeExternalCollaborators?: boolean; } interface OrganizationMembership { @@ -151,66 +195,227 @@ function isOrganizationAdmin(membership: OrganizationMembership): boolean { return membership.role === "owner" || membership.role === "admin"; } -async function mutationAccess( - actorInput: AutomationActor, - resource: Resource, - meta: JobFrontmatter, -): Promise<{ actor: AutomationActor; canUpdate: boolean }> { - const actor = normalizeActor(actorInput); - const resourceOrgId = organizationIdFromResourceOwner(resource.owner); - if (!resourceOrgId) { - return { - actor, - canUpdate: resource.owner.toLowerCase() === actor.userEmail, - }; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function validateSharingState( + actor: AutomationActor, + input: CompleteAutomationSharingState, + owningOrganizationId: string | null, + acknowledgeExternalCollaborators: boolean | undefined, +): Promise { + if (input.kind === "personal") return input; + + const requestedOrganizationId = + input.kind === "organization" + ? input.organizationId.trim() + : input.organizationId?.trim() || null; + if (input.kind === "organization" && !requestedOrganizationId) { + throw httpError( + "An organization is required for organization sharing.", + 400, + ); + } + if ( + owningOrganizationId && + requestedOrganizationId && + requestedOrganizationId !== owningOrganizationId + ) { + throw httpError( + "Automation sharing must use the automation's owning organization.", + 400, + ); + } + const organizationId = owningOrganizationId ?? requestedOrganizationId; + if (organizationId) { + if (actor.orgId !== organizationId) { + throw httpError( + "The automation's organization must be the current organization.", + 400, + ); + } + await requireOrganizationMembership(actor); + } + + if (input.kind === "organization") { + return { kind: "organization", organizationId: organizationId! }; + } + if (input.kind !== "specific") { + throw httpError("Unsupported automation sharing state.", 400); + } + + if (!input.grants.length) { + throw httpError("Specific sharing requires at least one account.", 400); + } + const grants = input.grants.map((grant) => { + const email = normalizeAutomationSharingEmail(grant.email); + if (!EMAIL_RE.test(email)) { + throw httpError(`Invalid sharing account email "${email}".`, 400); + } + if (grant.role !== "view" && grant.role !== "collaborate") { + throw httpError("Sharing role must be view or collaborate.", 400); + } + return { email, role: grant.role }; + }); + if (new Set(grants.map((grant) => grant.email)).size !== grants.length) { + throw httpError("Sharing accounts must be unique.", 400); + } + + const placeholders = grants.map(() => "?").join(", "); + const accounts = await getDbExec().execute({ + sql: `SELECT LOWER(email) AS email FROM "user" WHERE LOWER(email) IN (${placeholders})`, + args: grants.map((grant) => grant.email), + }); + const existing = new Set( + accounts.rows.map((row) => + String(row.email ?? "") + .trim() + .toLowerCase(), + ), + ); + const missing = grants + .map((grant) => grant.email) + .filter((email) => !existing.has(email)); + if (missing.length) { + throw httpError( + `Sharing accounts do not exist: ${missing.join(", ")}.`, + 400, + ); } - if (actor.orgId !== resourceOrgId) { - return { actor, canUpdate: false }; + + if (organizationId) { + const memberships = await getDbExec().execute({ + sql: `SELECT LOWER(email) AS email FROM org_members WHERE org_id = ? AND LOWER(email) IN (${placeholders})`, + args: [organizationId, ...grants.map((grant) => grant.email)], + }); + const memberEmails = new Set( + memberships.rows.map((row) => + String(row.email ?? "") + .trim() + .toLowerCase(), + ), + ); + const outsideCollaborators = grants.filter( + (grant) => grant.role === "collaborate" && !memberEmails.has(grant.email), + ); + if (outsideCollaborators.length && !acknowledgeExternalCollaborators) { + throw httpError( + `Acknowledge outside-organization collaborators before sharing with: ${outsideCollaborators.map((grant) => grant.email).join(", ")}.`, + 400, + ); + } } - const membership = await requireOrganizationMembership(actor); - const isCreator = - meta.createdBy?.trim().toLowerCase() === actor.userEmail.toLowerCase(); + return { - actor, - canUpdate: isCreator || isOrganizationAdmin(membership), + kind: "specific", + organizationId, + grants, }; } -/** - * Compatibility adapters may expose both explicit automations and legacy - * scheduled jobs. Keep their mutation authorization on the same boundary as - * the canonical service without forcing legacy resources through the explicit - * automation classifier. - */ -export async function canUpdateAutomationResource( - actorInput: AutomationActor, - resource: Resource, -): Promise { - const { meta } = parseJobResource(resource.content); - return (await mutationAccess(actorInput, resource, meta)).canUpdate; +interface D1AutomationMutation { + statements: readonly DbExecStatement[]; + value: T; + successStatementIndex: number; + condition: { sql: string; args: readonly unknown[] }; + conflictError: () => Error; } -function assertExplicitAutomation( - resource: Resource, -): Omit { - const parsed = parseJobResource(resource.content); - if (parsed.classification.kind !== "automation") { +async function runAtomicAutomationMutation( + work: (tx: DbExec) => Promise, + d1: D1AutomationMutation, +): Promise { + await Promise.all([ + ensureResourceStoreReady(), + ensureAutomationSharingTables(), + ]); + const client = getDbExec(); + if (getDialect() === "d1") { + if (!client.atomicBatch) { + throw new Error("D1 automation writes require atomic batch support."); + } + const assertion = prepareResourceBatchAssertion(d1.condition); + const statements: DbExecStatement[] = [ + ...assertion.statements, + ...d1.statements, + assertion.cleanupStatement, + ]; + let results; + try { + results = await client.atomicBatch(statements); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/unique constraint failed:\s*resources\./i.test(message)) { + throw d1.conflictError(); + } + throw error; + } + if (results[d1.successStatementIndex + 2]?.rowsAffected !== 1) { + throw d1.conflictError(); + } + return d1.value; + } + if (!client.transaction) { + throw new Error("Atomic automation writes require transaction support."); + } + return client.transaction(work); +} + +function resourceGuard(resource: Resource): { + sql: string; + args: readonly unknown[]; +} { + return { + sql: "SELECT 1 FROM resources WHERE id = ? AND owner = ? AND path = ? AND updated_at = ? AND content = ?", + args: [ + resource.id, + resource.owner, + resource.path, + resource.updatedAt, + resource.content, + ], + }; +} + +function definitionFromAccess( + access: AccessibleAutomation, +): AutomationDefinition { + if (access.classification.kind !== "automation") { throw httpError( - `"${automationName(resource.path)}" is a legacy scheduled job. Use manage-jobs for compatibility.`, + `"${access.name}" is a legacy scheduled job. Use manage-jobs for compatibility.`, 400, ); } return { - resource, + resource: access.resource, + name: access.name, + scope: access.owningOrganizationId ? "organization" : "personal", meta: { - ...parsed.meta, - triggerType: parsed.classification.triggerType, - mode: parsed.meta.mode ?? "agentic", + ...access.meta, + triggerType: access.classification.triggerType, + mode: access.meta.mode ?? "agentic", }, - body: parsed.body, + body: access.body, + canUpdate: access.capabilities.canEdit, }; } +/** + * Compatibility adapters may expose both explicit automations and legacy + * scheduled jobs. Keep their mutation authorization on the same boundary as + * the canonical service without forcing legacy resources through the explicit + * automation classifier. + */ +export async function canUpdateAutomationResource( + actorInput: AutomationActor, + resource: Resource, +): Promise { + const access = await resolveAutomationAccess( + normalizeActor(actorInput), + resource.id, + ); + return access?.capabilities.canEdit ?? false; +} + async function readDefinition( actorInput: AutomationActor, scope: AutomationScope, @@ -224,16 +429,59 @@ async function readDefinition( if (!resource) { throw httpError(`Automation "${automationName(path)}" not found.`, 404); } - const definition = assertExplicitAutomation(resource); - const access = await mutationAccess(actor, resource, definition.meta); - return { + const access = await resolveAutomationAccess(actor, resource.id); + if (!access) { + throw httpError(`Automation "${automationName(path)}" not found.`, 404); + } + return definitionFromAccess(access); +} + +async function readDefinitionForUpdate( + actorInput: AutomationActor, + input: UpdateAutomationInput, +): Promise<{ definition: AutomationDefinition; access: AccessibleAutomation }> { + const actor = normalizeActor(actorInput); + let access: AccessibleAutomation | null; + if (input.resourceId?.trim()) { + access = await resolveAutomationAccess(actor, input.resourceId.trim()); + } else { + if (!input.scope || !input.name) { + throw httpError( + "Automation resource id or name and scope is required.", + 400, + ); + } + const definition = await readDefinition(actor, input.scope, input.name); + access = await resolveAutomationAccess(actor, definition.resource.id); + } + if (!access) throw httpError("Automation not found.", 404); + return { definition: definitionFromAccess(access), access }; +} + +export async function listAccessibleAutomationDefinitions( + actorInput: AutomationActor, +): Promise { + const actor = normalizeActor(actorInput); + const definitions = await listAccessibleAutomations(actor); + return definitions.map((definition) => ({ ...definition, - name: automationName(resource.path), - scope, - canUpdate: access.canUpdate, - }; + meta: { + ...definition.meta, + triggerType: + definition.classification.kind === "automation" + ? definition.classification.triggerType + : "schedule", + }, + scope: definition.owningOrganizationId ? "organization" : "personal", + canUpdate: definition.capabilities.canEdit, + })); } +/** + * Scoped explicit-only compatibility wrapper. New list consumers should use + * listAccessibleAutomationDefinitions so shared and legacy rows are not split + * into parallel authorization paths. + */ export async function listAutomationDefinitions( actorInput: AutomationActor, scope: AutomationScope, @@ -313,7 +561,11 @@ export async function defineAutomation( throw httpError("event is required for event-triggered automations.", 400); } - if (input.timezone && !isValidTimezone(input.timezone)) { + if ( + input.triggerType === "schedule" && + input.timezone && + !isValidTimezone(input.timezone) + ) { throw httpError(`Unknown timezone "${input.timezone}".`, 400); } // Resolve now and persist it: a schedule whose zone is implicit means @@ -326,21 +578,24 @@ export async function defineAutomation( const mcpTools = normalizeJobMcpTools(input.mcpTools); const meta: JobFrontmatter = { schedule: input.triggerType === "schedule" ? schedule : "", - timezone, - enabled: true, + ...(timezone ? { timezone } : {}), + enabled: input.enabled ?? true, triggerType: input.triggerType, - event: input.triggerType === "event" ? event : undefined, - condition: input.condition?.trim() || undefined, + ...(input.triggerType === "event" ? { event } : {}), + ...(input.triggerType !== "manual" && input.condition?.trim() + ? { condition: input.condition.trim() } + : {}), mode: "agentic", domain: input.domain?.trim() || undefined, delegatedPolicyId: input.delegatedPolicyId?.trim() || undefined, createdBy: actor.userEmail, orgId: input.scope === "organization" ? actor.orgId! : undefined, runAs: "creator", - nextRun: - input.triggerType === "schedule" - ? nextOccurrence(schedule, undefined, timezone).toISOString() - : undefined, + ...(input.triggerType === "schedule" + ? { + nextRun: nextOccurrence(schedule, undefined, timezone).toISOString(), + } + : {}), model: input.model?.trim() || undefined, mcpTools: mcpTools?.length ? mcpTools : undefined, originScopeId: input.delivery?.originScopeId, @@ -349,9 +604,66 @@ export async function defineAutomation( deliveryThreadRef: input.delivery?.threadRef, deliveryTenantId: input.delivery?.tenantId, }; + const sharing = await validateSharingState( + actor, + input.sharing ?? + (input.scope === "organization" + ? { kind: "organization", organizationId: actor.orgId! } + : { kind: "personal" }), + input.scope === "organization" ? actor.orgId! : null, + input.acknowledgeExternalCollaborators, + ); const content = buildJobResourceContent(meta, body); - await resourcePut(owner, path, content); + const preparedWrite = prepareResourceCreate({ + id: crypto.randomUUID(), + owner, + path, + content, + }); + const preparedSharing = prepareAutomationSharingReplacement( + preparedWrite.value.id, + sharing, + { + guard: { + sql: "SELECT 1 FROM resources WHERE id = ? AND owner = ? AND path = ?", + args: [preparedWrite.value.id, owner, path], + }, + }, + ); + let write: TransactionScopedResourceWrite = preparedWrite; + await runAtomicAutomationMutation( + async (tx) => { + const existing = await tx.execute({ + sql: "SELECT id FROM resources WHERE owner = ? AND path = ? LIMIT 1", + args: [owner, path], + }); + if (existing.rows.length) { + throw httpError( + `An automation named "${automationName(path)}" already exists.`, + 409, + ); + } + write = await resourcePutWithDb(tx, owner, path, content); + await replaceAutomationSharingStateWithDb(tx, write.value.id, sharing); + }, + { + statements: [...preparedWrite.statements, ...preparedSharing.statements], + value: undefined, + successStatementIndex: 0, + condition: { + sql: "NOT EXISTS (SELECT 1 FROM resources WHERE owner = ? AND path = ?)", + args: [owner, path], + }, + conflictError: () => + httpError( + `An automation named "${automationName(path)}" already exists.`, + 409, + ), + }, + ); + write.notifyAfterCommit(); return { + resourceId: write.value.id, name: automationName(path), scope: input.scope, meta: { ...meta, triggerType: input.triggerType, mode: "agentic" }, @@ -364,46 +676,86 @@ export async function updateAutomation( actorInput: AutomationActor, input: UpdateAutomationInput, ): Promise { - const definition = await readDefinition(actorInput, input.scope, input.name); - if (!definition.canUpdate) { + const actor = normalizeActor(actorInput); + const { definition, access } = await readDefinitionForUpdate(actor, input); + if (!access.capabilities.canEdit) { throw httpError( - "Only the automation's creator or an organization admin can update it.", + "Collaborate access is required to update an automation.", 403, ); } - const { meta } = definition; - if (input.schedule !== undefined) { - if (meta.triggerType !== "schedule") { - throw httpError("Event automations do not have a cron schedule.", 400); + if (input.sharing && !access.capabilities.canManageSharing) { + throw httpError("Only the automation owner can change sharing.", 403); + } + const meta: AutomationDefinition["meta"] = { ...definition.meta }; + const triggerType = input.triggerType ?? meta.triggerType; + const schedule = input.schedule?.trim() ?? meta.schedule; + const event = input.event?.trim() ?? meta.event; + + if (triggerType === "schedule") { + if (!isValidCron(schedule)) { + throw httpError( + schedule + ? `Invalid cron expression "${schedule}".` + : "schedule is required for scheduled automations.", + 400, + ); } - if (!isValidCron(input.schedule)) { - throw httpError(`Invalid cron expression "${input.schedule}".`, 400); + if (input.event !== undefined) { + throw httpError("Scheduled automations do not have an event.", 400); } - meta.schedule = input.schedule; - } - if (input.timezone !== undefined) { - if (!isValidTimezone(input.timezone)) { - throw httpError(`Unknown timezone "${input.timezone}".`, 400); + const timezone = + input.timezone ?? + meta.timezone ?? + (await resolveUserSchedulingTimezone( + definition.meta.createdBy?.trim() || actorInput.userEmail, + )); + if (!isValidTimezone(timezone)) { + throw httpError(`Unknown timezone "${timezone}".`, 400); } - if (meta.triggerType !== "schedule") { - throw httpError("Event automations do not have a timezone.", 400); + meta.triggerType = "schedule"; + meta.schedule = schedule; + meta.timezone = timezone; + meta.event = undefined; + // No schedule-valid condition policy exists yet; a condition left over + // from a prior event trigger must not silently keep gating schedule runs. + meta.condition = undefined; + meta.nextRun = nextOccurrence(schedule, undefined, timezone).toISOString(); + } else if (triggerType === "event") { + if (!event) { + throw httpError( + "event is required for event-triggered automations.", + 400, + ); } - meta.timezone = input.timezone; - } - if (input.schedule !== undefined || input.timezone !== undefined) { - meta.nextRun = nextOccurrence( - meta.schedule, - undefined, - meta.timezone, - ).toISOString(); + if (input.schedule !== undefined || input.timezone !== undefined) { + throw httpError("Event automations do not have schedule settings.", 400); + } + meta.triggerType = "event"; + meta.schedule = ""; + meta.timezone = undefined; + meta.event = event; + meta.nextRun = undefined; + } else { + if ( + input.event !== undefined || + input.schedule !== undefined || + input.timezone !== undefined || + input.condition !== undefined + ) { + throw httpError("Manual automations do not have trigger settings.", 400); + } + meta.triggerType = "manual"; + meta.schedule = ""; + meta.timezone = undefined; + meta.event = undefined; + meta.condition = undefined; + meta.nextRun = undefined; } + if (input.enabled !== undefined) { meta.enabled = input.enabled; - if ( - input.enabled && - meta.triggerType === "schedule" && - isValidCron(meta.schedule) - ) { + if (input.enabled && meta.triggerType === "schedule") { meta.nextRun = nextOccurrence( meta.schedule, undefined, @@ -412,6 +764,12 @@ export async function updateAutomation( } } if (input.condition !== undefined) { + if (meta.triggerType !== "event") { + throw httpError( + "Only event-triggered automations support a condition.", + 400, + ); + } meta.condition = input.condition?.trim() || undefined; } if (input.delegatedPolicyId !== undefined) { @@ -424,36 +782,167 @@ export async function updateAutomation( const mcpTools = normalizeJobMcpTools(input.mcpTools); meta.mcpTools = mcpTools?.length ? mcpTools : undefined; } - if (input.scope === "organization") { - meta.orgId = organizationIdFromResourceOwner(definition.resource.owner)!; + const owningOrganizationId = organizationIdFromResourceOwner( + definition.resource.owner, + ); + meta.createdBy = definition.meta.createdBy; + meta.runAs = definition.meta.runAs; + meta.orgId = definition.meta.orgId; + if (owningOrganizationId) { + meta.orgId = owningOrganizationId; meta.runAs = "creator"; } const body = input.body === undefined ? definition.body : input.body.trim(); if (!body) throw httpError("Automation body is required.", 400); - await resourcePut( - definition.resource.owner, - definition.resource.path, - buildJobResourceContent(meta, body), + const sharing = input.sharing + ? await validateSharingState( + actor, + input.sharing, + owningOrganizationId, + input.acknowledgeExternalCollaborators, + ) + : undefined; + const content = buildJobResourceContent(meta, body); + const preparedWrite = prepareResourceUpdate({ + current: definition.resource, + content, + }); + const preparedSharing = sharing + ? prepareAutomationSharingReplacement(preparedWrite.value.id, sharing, { + guard: resourceGuard(preparedWrite.value), + }) + : undefined; + let write: TransactionScopedResourceWrite = preparedWrite; + const updateConflictError = () => + httpError("Automation changed while it was being updated.", 409); + await runAtomicAutomationMutation( + async (tx) => { + const current = await tx.execute({ + sql: "SELECT id FROM resources WHERE owner = ? AND path = ? LIMIT 1", + args: [definition.resource.owner, definition.resource.path], + }); + if (String(current.rows[0]?.id ?? "") !== definition.resource.id) { + throw httpError("Automation not found.", 404); + } + // Same optimistic-concurrency guard as the D1 batch path: the prepared + // statement's WHERE clause only matches the row this update read, so a + // concurrent writer makes rowsAffected 0 instead of silently upserting. + const result = await tx.execute(preparedWrite.statements[0]); + if (result.rowsAffected !== 1) { + throw updateConflictError(); + } + write = preparedWrite; + if (sharing) { + await replaceAutomationSharingStateWithDb(tx, write.value.id, sharing); + } + }, + { + statements: [ + ...preparedWrite.statements, + ...(preparedSharing?.statements ?? []), + ], + value: undefined, + successStatementIndex: 0, + condition: { + sql: "EXISTS (SELECT 1 FROM resources WHERE id = ? AND owner = ? AND path = ? AND updated_at = ? AND content = ?)", + args: [ + definition.resource.id, + definition.resource.owner, + definition.resource.path, + definition.resource.updatedAt, + definition.resource.content, + ], + }, + conflictError: updateConflictError, + }, ); - return { ...definition, meta, body }; + write.notifyAfterCommit(); + return { ...definition, resource: write.value, meta, body }; } +export type DeleteAutomationInput = + | { resourceId: string } + | { scope: AutomationScope; name: string }; + export async function deleteAutomation( actorInput: AutomationActor, - scope: AutomationScope, - name: string, + scopeOrInput: AutomationScope | DeleteAutomationInput, + compatibilityName?: string, ): Promise { - const definition = await readDefinition(actorInput, scope, name); - if (!definition.canUpdate) { - throw httpError( - "Only the automation's creator or an organization admin can delete it.", - 403, - ); + const actor = normalizeActor(actorInput); + const input: DeleteAutomationInput = + typeof scopeOrInput === "string" + ? { scope: scopeOrInput, name: compatibilityName ?? "" } + : scopeOrInput; + let access: AccessibleAutomation | null; + if ("resourceId" in input) { + access = await resolveAutomationAccess(actor, input.resourceId.trim()); + } else { + const definition = await readDefinition(actor, input.scope, input.name); + access = await resolveAutomationAccess(actor, definition.resource.id); } - await resourceDelete(definition.resource.id); - // Names are reusable, so leaving history behind would attach these runs to - // whatever automation is created under the same name next. - await deleteAutomationRuns(definition.resource.owner, name); + if (!access) throw httpError("Automation not found.", 404); + const definition = definitionFromAccess(access); + if (!access.capabilities.canDelete) { + throw httpError("Only the automation owner can delete it.", 403); + } + + await ensureAutomationRunHistoryReady(); + const preparedWrite = prepareResourceDelete(definition.resource); + const guard = resourceGuard(definition.resource); + const preparedSharing = prepareAutomationSharingDelete( + definition.resource.id, + guard, + ); + const preparedHistory = prepareAutomationRunsDelete( + definition.resource.owner, + definition.name, + guard, + ); + const d1Statements = [ + ...preparedSharing.statements, + preparedHistory, + ...preparedWrite.statements, + ]; + let write: TransactionScopedResourceWrite = preparedWrite; + const deleteConflictError = () => + httpError("Automation changed while it was being deleted.", 409); + await runAtomicAutomationMutation( + async (tx) => { + // Same optimistic-concurrency guard as the D1 batch path: the prepared + // statement's WHERE clause only matches the row this delete read, so a + // concurrent writer makes rowsAffected 0 instead of silently deleting + // whatever now lives at that id. + const result = await tx.execute(preparedWrite.statements[0]); + if (result.rowsAffected !== 1) { + throw deleteConflictError(); + } + write = preparedWrite; + await deleteAutomationSharingStateWithDb(tx, definition.resource.id); + await deleteAutomationRunsWithDb( + tx, + definition.resource.owner, + definition.name, + ); + }, + { + statements: d1Statements, + value: undefined, + successStatementIndex: d1Statements.length - 1, + condition: { + sql: "EXISTS (SELECT 1 FROM resources WHERE id = ? AND owner = ? AND path = ? AND updated_at = ? AND content = ?)", + args: [ + definition.resource.id, + definition.resource.owner, + definition.resource.path, + definition.resource.updatedAt, + definition.resource.content, + ], + }, + conflictError: deleteConflictError, + }, + ); + write.notifyAfterCommit(); } export interface AutomationExecutionIdentity { diff --git a/packages/core/src/automations/sharing-store.spec.ts b/packages/core/src/automations/sharing-store.spec.ts new file mode 100644 index 0000000000..5858c9214b --- /dev/null +++ b/packages/core/src/automations/sharing-store.spec.ts @@ -0,0 +1,255 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let sqlite: Database.Database; +let postgres = false; +let dialect: "sqlite" | "postgres" | "d1" = "sqlite"; + +const execute = vi.fn( + async (input: string | { sql: string; args?: unknown[] }) => { + const sql = typeof input === "string" ? input : input.sql; + const args = typeof input === "string" ? [] : (input.args ?? []); + const statement = sqlite.prepare(sql); + if (/^\s*(select|pragma)/i.test(sql)) { + return { rows: statement.all(...args), rowsAffected: 0 }; + } + const result = statement.run(...args); + return { rows: [], rowsAffected: result.changes }; + }, +); + +const client = { + execute, + async transaction(run: (tx: typeof client) => Promise): Promise { + sqlite.exec("BEGIN IMMEDIATE"); + try { + const result = await run(client); + sqlite.exec("COMMIT"); + return result; + } catch (error) { + sqlite.exec("ROLLBACK"); + throw error; + } + }, +}; + +const ddlMocks = vi.hoisted(() => ({ + ensureIndexExists: vi.fn().mockResolvedValue(true), + ensureTableExists: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../db/client.js", () => ({ + getDbExec: () => client, + getDialect: () => dialect, + isPostgres: () => postgres, + retryOnDdlRace: (run: () => unknown) => run(), +})); + +vi.mock("../db/ddl-guard.js", () => ddlMocks); + +const { + __resetAutomationSharingStoreForTests, + deleteAutomationSharingStateWithDb, + ensureAutomationSharingTables, + getAutomationSharingState, + loadAutomationSharingOverlays, + prepareAutomationSharingDelete, + prepareAutomationSharingReplacement, + replaceAutomationSharingState, +} = await import("./sharing-store.js"); + +beforeEach(() => { + sqlite = new Database(":memory:"); + postgres = false; + dialect = "sqlite"; + execute.mockClear(); + ddlMocks.ensureIndexExists.mockClear(); + ddlMocks.ensureTableExists.mockClear(); + __resetAutomationSharingStoreForTests(); +}); + +afterEach(() => sqlite.close()); + +describe("automation sharing store", () => { + it("initializes the additive tables and indexes idempotently", async () => { + await ensureAutomationSharingTables(); + await ensureAutomationSharingTables(); + + const tables = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'automation_sharing_%' ORDER BY name", + ) + .all() as Array<{ name: string }>; + expect(tables.map(({ name }) => name)).toEqual([ + "automation_sharing_grants", + "automation_sharing_overlays", + ]); + + const indexes = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_automation_sharing_%' ORDER BY name", + ) + .all() as Array<{ name: string }>; + expect(indexes.map(({ name }) => name)).toEqual([ + "idx_automation_sharing_grants_user", + "idx_automation_sharing_overlays_organization", + ]); + }); + + it("uses guarded table and index creation on Postgres", async () => { + postgres = true; + dialect = "postgres"; + await ensureAutomationSharingTables(); + + expect(ddlMocks.ensureTableExists).toHaveBeenCalledTimes(2); + expect(ddlMocks.ensureIndexExists).toHaveBeenCalledTimes(2); + expect(execute).not.toHaveBeenCalled(); + }); + + it("normalizes emails, enforces uniqueness, and replaces the complete state", async () => { + await replaceAutomationSharingState("job-1", { + kind: "specific", + organizationId: " org-1 ", + grants: [ + { email: " Alice@Example.com ", role: "view" }, + { email: "alice@example.com", role: "collaborate" }, + { email: "bob@example.com", role: "view" }, + ], + }); + + expect(await getAutomationSharingState("job-1")).toMatchObject({ + kind: "specific", + visibility: "private", + organizationId: "org-1", + grants: [ + { email: "alice@example.com", role: "collaborate" }, + { email: "bob@example.com", role: "view" }, + ], + }); + + await replaceAutomationSharingState("job-1", { + kind: "organization", + organizationId: "org-1", + }); + expect(await getAutomationSharingState("job-1")).toEqual({ + resourceId: "job-1", + kind: "organization", + visibility: "organization", + organizationId: "org-1", + grants: [], + }); + expect( + sqlite + .prepare( + "SELECT COUNT(*) AS count FROM automation_sharing_grants WHERE resource_id = ?", + ) + .get("job-1"), + ).toEqual({ count: 0 }); + }); + + it("prepares guarded replacement and cleanup statements for a larger atomic batch", () => { + const guard = { + sql: "SELECT 1 FROM resources WHERE id = ? AND updated_at = ?", + args: ["job-1", 10], + }; + const replacement = prepareAutomationSharingReplacement( + "job-1", + { + kind: "specific", + grants: [{ email: "viewer@example.com", role: "view" }], + }, + { now: 20, guard }, + ); + expect(replacement.statements).toHaveLength(4); + expect(replacement.statements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sql: expect.stringContaining("EXISTS") }), + ]), + ); + expect( + replacement.statements.every( + (statement) => + typeof statement !== "string" && statement.args?.includes("job-1"), + ), + ).toBe(true); + + const cleanup = prepareAutomationSharingDelete("job-1", guard); + expect(cleanup.statements).toHaveLength(2); + expect(cleanup.statements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sql: expect.stringContaining("EXISTS") }), + ]), + ); + }); + + it("cleans an automation's complete sharing state through the caller transaction", async () => { + await replaceAutomationSharingState("job-1", { + kind: "specific", + grants: [{ email: "viewer@example.com", role: "view" }], + }); + + await client.transaction((tx) => + deleteAutomationSharingStateWithDb(tx, "job-1"), + ); + + expect(await getAutomationSharingState("job-1")).toBeNull(); + }); + + it("loads resource overlays in bounded batches", async () => { + await ensureAutomationSharingTables(); + const insert = sqlite.prepare( + "INSERT INTO automation_sharing_overlays (resource_id, visibility, organization_id, created_at, updated_at) VALUES (?, 'private', NULL, 1, 1)", + ); + for (let index = 0; index < 201; index++) insert.run(`job-${index}`); + execute.mockClear(); + + const overlays = await loadAutomationSharingOverlays( + Array.from({ length: 201 }, (_, index) => `job-${index}`), + ); + + expect(overlays).toHaveLength(201); + const reads = execute.mock.calls.filter(([input]) => + /FROM automation_sharing_overlays WHERE resource_id IN/.test( + typeof input === "string" ? input : input.sql, + ), + ); + expect(reads).toHaveLength(2); + }); + + it("rolls back a failed complete replacement", async () => { + await replaceAutomationSharingState("job-1", { + kind: "specific", + grants: [{ email: "old@example.com", role: "view" }], + }); + sqlite.exec(` + CREATE TRIGGER reject_new_grant + BEFORE INSERT ON automation_sharing_grants + WHEN NEW.user_email = 'reject@example.com' + BEGIN + SELECT RAISE(ABORT, 'rejected test grant'); + END + `); + + await expect( + replaceAutomationSharingState("job-1", { + kind: "specific", + grants: [{ email: "reject@example.com", role: "collaborate" }], + }), + ).rejects.toThrow("rejected test grant"); + + expect(await getAutomationSharingState("job-1")).toMatchObject({ + kind: "specific", + grants: [{ email: "old@example.com", role: "view" }], + }); + }); + + it("rejects incomplete or unsupported sharing state before writing", async () => { + await expect( + replaceAutomationSharingState("job-1", { + kind: "specific", + grants: [], + }), + ).rejects.toThrow("at least one grant"); + expect(await getAutomationSharingState("job-1")).toBeNull(); + }); +}); diff --git a/packages/core/src/automations/sharing-store.ts b/packages/core/src/automations/sharing-store.ts new file mode 100644 index 0000000000..1f70ad40f4 --- /dev/null +++ b/packages/core/src/automations/sharing-store.ts @@ -0,0 +1,477 @@ +import { + getDbExec, + getDialect, + isPostgres, + retryOnDdlRace, + type DbExec, + type DbExecStatement, +} from "../db/client.js"; +import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js"; + +export type AutomationSharingVisibility = "private" | "organization"; +export type AutomationSharingGrantRole = "view" | "collaborate"; + +export interface AutomationSharingGrantInput { + email: string; + role: AutomationSharingGrantRole; +} + +export type CompleteAutomationSharingState = + | { + kind: "personal"; + } + | { + kind: "organization"; + organizationId: string; + } + | { + kind: "specific"; + organizationId?: string | null; + grants: readonly AutomationSharingGrantInput[]; + }; + +export interface AutomationSharingOverlayRow { + resourceId: string; + visibility: AutomationSharingVisibility; + organizationId: string | null; + createdAt: number; + updatedAt: number; +} + +export interface AutomationSharingGrantRow { + resourceId: string; + email: string; + role: AutomationSharingGrantRole; + createdAt: number; + updatedAt: number; +} + +export interface AutomationMutationGuard { + sql: string; + args: readonly unknown[]; +} + +export interface PreparedAutomationSharingMutation { + statements: DbExecStatement[]; + summary?: AutomationSharingSummary; +} + +export type AutomationSharingSummary = + | { + resourceId: string; + kind: "personal"; + visibility: "private"; + organizationId: null; + grants: []; + } + | { + resourceId: string; + kind: "organization"; + visibility: "organization"; + organizationId: string; + grants: []; + } + | { + resourceId: string; + kind: "specific"; + visibility: "private"; + organizationId: string | null; + grants: AutomationSharingGrantRow[]; + }; + +const OVERLAYS_TABLE = "automation_sharing_overlays"; +const GRANTS_TABLE = "automation_sharing_grants"; +const READ_BATCH_SIZE = 200; + +let initPromise: Promise | undefined; + +function normalizedRequired(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +export function normalizeAutomationSharingEmail(email: string): string { + return normalizedRequired(email, "Sharing grant email").toLowerCase(); +} + +function normalizeCompleteState(input: CompleteAutomationSharingState): { + visibility: AutomationSharingVisibility; + organizationId: string | null; + grants: AutomationSharingGrantInput[]; +} { + if (input.kind === "personal") { + return { visibility: "private", organizationId: null, grants: [] }; + } + if (input.kind === "organization") { + return { + visibility: "organization", + organizationId: normalizedRequired( + input.organizationId, + "Organization id", + ), + grants: [], + }; + } + if (input.kind !== "specific") { + throw new Error("Unsupported automation sharing state."); + } + + const grants = new Map(); + for (const grant of input.grants) { + if (grant.role !== "view" && grant.role !== "collaborate") { + throw new Error("Sharing grant role must be view or collaborate."); + } + grants.set(normalizeAutomationSharingEmail(grant.email), grant.role); + } + if (grants.size === 0) { + throw new Error("Specific sharing requires at least one grant."); + } + + return { + visibility: "private", + organizationId: input.organizationId?.trim() || null, + grants: [...grants].map(([email, role]) => ({ email, role })), + }; +} + +function overlayFromRow( + row: Record, +): AutomationSharingOverlayRow { + const visibility = String(row.visibility); + if (visibility !== "private" && visibility !== "organization") { + throw new Error( + `Invalid stored automation sharing visibility: ${visibility}`, + ); + } + return { + resourceId: String(row.resource_id), + visibility, + organizationId: + row.organization_id == null ? null : String(row.organization_id), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +function grantFromRow(row: Record): AutomationSharingGrantRow { + const role = String(row.role); + if (role !== "view" && role !== "collaborate") { + throw new Error(`Invalid stored automation sharing grant role: ${role}`); + } + return { + resourceId: String(row.resource_id), + email: normalizeAutomationSharingEmail(String(row.user_email)), + role, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +function chunks(values: readonly T[], size: number): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +async function ensureTables(): Promise { + const client = getDbExec(); + const createOverlaysSql = ` + CREATE TABLE IF NOT EXISTS ${OVERLAYS_TABLE} ( + resource_id TEXT PRIMARY KEY, + visibility TEXT NOT NULL, + organization_id TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK (visibility IN ('private', 'organization')) + ) + `; + const createGrantsSql = ` + CREATE TABLE IF NOT EXISTS ${GRANTS_TABLE} ( + resource_id TEXT NOT NULL, + user_email TEXT NOT NULL, + role TEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (resource_id, user_email), + CHECK (role IN ('view', 'collaborate')) + ) + `; + const organizationIndexSql = `CREATE INDEX IF NOT EXISTS idx_automation_sharing_overlays_organization ON ${OVERLAYS_TABLE} (organization_id, visibility, resource_id)`; + const userGrantIndexSql = `CREATE INDEX IF NOT EXISTS idx_automation_sharing_grants_user ON ${GRANTS_TABLE} (user_email, resource_id)`; + + if (isPostgres()) { + await ensureTableExists(OVERLAYS_TABLE, createOverlaysSql); + await ensureTableExists(GRANTS_TABLE, createGrantsSql); + await ensureIndexExists( + "idx_automation_sharing_overlays_organization", + organizationIndexSql, + ); + await ensureIndexExists( + "idx_automation_sharing_grants_user", + userGrantIndexSql, + ); + return; + } + + await retryOnDdlRace(() => client.execute(createOverlaysSql)); + await retryOnDdlRace(() => client.execute(createGrantsSql)); + await retryOnDdlRace(() => client.execute(organizationIndexSql)); + await retryOnDdlRace(() => client.execute(userGrantIndexSql)); +} + +export async function ensureAutomationSharingTables(): Promise { + if (!initPromise) { + initPromise = ensureTables().catch((error) => { + initPromise = undefined; + throw error; + }); + } + await initPromise; +} + +export async function loadAutomationSharingOverlays( + resourceIds: readonly string[], + client: DbExec = getDbExec(), +): Promise> { + await ensureAutomationSharingTables(); + const ids = [...new Set(resourceIds.map((id) => id.trim()).filter(Boolean))]; + const overlays = new Map(); + for (const batch of chunks(ids, READ_BATCH_SIZE)) { + const result = await client.execute({ + sql: `SELECT resource_id, visibility, organization_id, created_at, updated_at FROM ${OVERLAYS_TABLE} WHERE resource_id IN (${batch.map(() => "?").join(", ")})`, + args: batch, + }); + for (const rawRow of result.rows) { + const row = overlayFromRow(rawRow as Record); + overlays.set(row.resourceId, row); + } + } + return overlays; +} + +export async function loadAutomationSharingGrants( + resourceIds: readonly string[], + client: DbExec = getDbExec(), +): Promise> { + await ensureAutomationSharingTables(); + const ids = [...new Set(resourceIds.map((id) => id.trim()).filter(Boolean))]; + const grants = new Map(); + for (const batch of chunks(ids, READ_BATCH_SIZE)) { + const result = await client.execute({ + sql: `SELECT resource_id, user_email, role, created_at, updated_at FROM ${GRANTS_TABLE} WHERE resource_id IN (${batch.map(() => "?").join(", ")}) ORDER BY resource_id, user_email`, + args: batch, + }); + for (const rawRow of result.rows) { + const row = grantFromRow(rawRow as Record); + const rows = grants.get(row.resourceId) ?? []; + rows.push(row); + grants.set(row.resourceId, rows); + } + } + return grants; +} + +export async function loadAutomationSharingStates( + resourceIds: readonly string[], + client: DbExec = getDbExec(), +): Promise> { + const [overlays, grantsByResource] = await Promise.all([ + loadAutomationSharingOverlays(resourceIds, client), + loadAutomationSharingGrants(resourceIds, client), + ]); + const states = new Map(); + for (const [resourceId, overlay] of overlays) { + const grants = grantsByResource.get(resourceId) ?? []; + if (overlay.visibility === "organization") { + if (!overlay.organizationId) { + throw new Error( + `Organization-visible automation ${resourceId} has no organization id.`, + ); + } + states.set(resourceId, { + resourceId, + kind: "organization", + visibility: "organization", + organizationId: overlay.organizationId, + grants: [], + }); + } else if (grants.length > 0) { + states.set(resourceId, { + resourceId, + kind: "specific", + visibility: "private", + organizationId: overlay.organizationId, + grants, + }); + } else { + states.set(resourceId, { + resourceId, + kind: "personal", + visibility: "private", + organizationId: null, + grants: [], + }); + } + } + return states; +} + +export async function getAutomationSharingState( + resourceId: string, + client: DbExec = getDbExec(), +): Promise { + const states = await loadAutomationSharingStates([resourceId], client); + return states.get(resourceId) ?? null; +} + +export function prepareAutomationSharingReplacement( + resourceId: string, + input: CompleteAutomationSharingState, + options?: { now?: number; guard?: AutomationMutationGuard }, +): { statements: DbExecStatement[]; summary: AutomationSharingSummary } { + const id = normalizedRequired(resourceId, "Automation resource id"); + const normalized = normalizeCompleteState(input); + const now = options?.now ?? Date.now(); + const guardSql = options?.guard ? ` AND EXISTS (${options.guard.sql})` : ""; + const insertGuardSql = options?.guard + ? ` WHERE EXISTS (${options.guard.sql})` + : ""; + const guardArgs = options?.guard ? [...options.guard.args] : []; + const statements: DbExecStatement[] = [ + { + sql: `DELETE FROM ${GRANTS_TABLE} WHERE resource_id = ?${guardSql}`, + args: [id, ...guardArgs], + }, + { + sql: `DELETE FROM ${OVERLAYS_TABLE} WHERE resource_id = ?${guardSql}`, + args: [id, ...guardArgs], + }, + { + sql: `INSERT INTO ${OVERLAYS_TABLE} (resource_id, visibility, organization_id, created_at, updated_at) SELECT ?, ?, ?, ?, ?${insertGuardSql}`, + args: [ + id, + normalized.visibility, + normalized.organizationId, + now, + now, + ...guardArgs, + ], + }, + ...normalized.grants.map( + (grant): DbExecStatement => ({ + sql: `INSERT INTO ${GRANTS_TABLE} (resource_id, user_email, role, created_at, updated_at) SELECT ?, ?, ?, ?, ?${insertGuardSql}`, + args: [id, grant.email, grant.role, now, now, ...guardArgs], + }), + ), + ]; + + const storedGrants: AutomationSharingGrantRow[] = normalized.grants.map( + (grant) => ({ + resourceId: id, + email: grant.email, + role: grant.role, + createdAt: now, + updatedAt: now, + }), + ); + const summary: AutomationSharingSummary = + input.kind === "organization" + ? { + resourceId: id, + kind: "organization", + visibility: "organization", + organizationId: normalized.organizationId!, + grants: [], + } + : input.kind === "specific" + ? { + resourceId: id, + kind: "specific", + visibility: "private", + organizationId: normalized.organizationId, + grants: storedGrants, + } + : { + resourceId: id, + kind: "personal", + visibility: "private", + organizationId: null, + grants: [], + }; + return { statements, summary }; +} + +export async function replaceAutomationSharingStateWithDb( + client: DbExec, + resourceId: string, + input: CompleteAutomationSharingState, +): Promise { + const { statements, summary } = prepareAutomationSharingReplacement( + resourceId, + input, + ); + for (const statement of statements) await client.execute(statement); + return summary; +} + +export function prepareAutomationSharingDelete( + resourceId: string, + guard?: AutomationMutationGuard, +): PreparedAutomationSharingMutation { + const id = normalizedRequired(resourceId, "Automation resource id"); + const guardSql = guard ? ` AND EXISTS (${guard.sql})` : ""; + const guardArgs = guard ? [...guard.args] : []; + return { + statements: [ + { + sql: `DELETE FROM ${GRANTS_TABLE} WHERE resource_id = ?${guardSql}`, + args: [id, ...guardArgs], + }, + { + sql: `DELETE FROM ${OVERLAYS_TABLE} WHERE resource_id = ?${guardSql}`, + args: [id, ...guardArgs], + }, + ], + }; +} + +export async function deleteAutomationSharingStateWithDb( + client: DbExec, + resourceId: string, +): Promise { + const prepared = prepareAutomationSharingDelete(resourceId); + for (const statement of prepared.statements) await client.execute(statement); +} + +export async function replaceAutomationSharingState( + resourceId: string, + input: CompleteAutomationSharingState, +): Promise { + await ensureAutomationSharingTables(); + const client = getDbExec(); + const replacement = prepareAutomationSharingReplacement(resourceId, input); + + if (getDialect() === "d1") { + if (!client.atomicBatch) { + throw new Error( + "D1 automation sharing replacement requires atomic batch support.", + ); + } + await client.atomicBatch(replacement.statements); + return replacement.summary; + } + if (!client.transaction) { + throw new Error("Automation sharing replacement requires transactions."); + } + await client.transaction(async (tx) => { + for (const statement of replacement.statements) await tx.execute(statement); + }); + return replacement.summary; +} + +export function __resetAutomationSharingStoreForTests(): void { + initPromise = undefined; +} diff --git a/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx b/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx index c858705ea8..590ec35180 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx @@ -11,22 +11,20 @@ const jobMocks = vi.hoisted(() => ({ useAutomationRuns: vi.fn(), useManageAutomation: vi.fn(), useManageRecurringJob: vi.fn(), - useRecurringJobs: vi.fn(), })); vi.mock("./use-jobs.js", () => ({ useAutomations: jobMocks.useAutomations, + useAutomationAccountSearch: () => ({ data: [], isFetching: false }), + useAutomationEvents: () => ({ data: [], error: null, isLoading: false }), useAutomationRuns: jobMocks.useAutomationRuns, useManageAutomation: jobMocks.useManageAutomation, useManageRecurringJob: jobMocks.useManageRecurringJob, - useRecurringJobs: jobMocks.useRecurringJobs, useRunAutomationNow: jobMocks.useRunAutomationNow, })); -vi.mock("../AgentAskPopover.js", () => ({ - AgentAskPopover: ({ title, label }: { title: string; label?: string }) => ( - - ), +vi.mock("../org/hooks.js", () => ({ + useOrg: () => ({ data: { orgId: null, orgName: null } }), })); vi.mock("../i18n.js", () => ({ @@ -63,34 +61,58 @@ describe("AgentJobsTab blocked automation", () => { document.body.appendChild(container); root = createRoot(container); - jobMocks.useRecurringJobs.mockImplementation((scope: "user" | "org") => - queryResult( - scope === "user" - ? [ - { - id: "blocked-job", - name: "competitive-intelligence-daily-email", - path: "jobs/competitive-intelligence-daily-email.md", - scope: "personal", - schedule: "0 8 * * *", - scheduleDescription: "Every day at 8 AM", - instructions: "Send the briefing.", - enabled: true, - // The job has never executed; a blocked tick only sets lastCheck. - lastRun: null, - lastCheck: "2026-07-31T17:04:14.688Z", - lastStatus: "skipped", - lastError: BLOCKED_REASON, - nextRun: "2026-08-01T08:00:00.000Z", - createdBy: "tmilazzo@builder.io", - mcpTools: [], - canUpdate: true, - }, - ] - : [], - ), + jobMocks.useAutomations.mockReturnValue( + queryResult([ + { + id: "blocked-job", + resourceId: "blocked-job", + name: "competitive-intelligence-daily-email", + path: "jobs/competitive-intelligence-daily-email.md", + scope: "personal", + classification: "recurring-job", + triggerType: "schedule", + event: null, + schedule: "0 8 * * *", + timezone: "UTC", + scheduleDescription: "Every day at 8 AM", + condition: null, + body: "Send the briefing.", + enabled: true, + // The job has never executed; a blocked tick only sets lastCheck. + lastRun: null, + lastCheck: "2026-07-31T17:04:14.688Z", + lastStatus: "skipped", + lastError: BLOCKED_REASON, + nextRun: "2026-08-01T08:00:00.000Z", + createdBy: "tmilazzo@builder.io", + model: null, + mcpTools: [], + originScopeId: null, + deliveryPlatform: null, + deliveryDestination: null, + deliveryThreadRef: null, + deliveryTenantId: null, + canUpdate: true, + effectiveRole: "owner", + capabilities: { + canEdit: true, + canOperate: true, + canDelete: true, + canManageSharing: true, + }, + sharing: { + source: "legacy", + visibility: "private", + organizationId: null, + grantCount: 0, + }, + creator: { + email: "tmilazzo@builder.io", + label: "tmilazzo@builder.io", + }, + }, + ]), ); - jobMocks.useAutomations.mockReturnValue(queryResult([])); jobMocks.useAutomationRuns.mockReturnValue(queryResult([])); jobMocks.useManageRecurringJob.mockReturnValue({ error: null, @@ -129,9 +151,17 @@ describe("AgentJobsTab blocked automation", () => { root.render(); }); - const row = container.querySelector("article"); - expect(row?.textContent).toContain("Last run: Never"); - expect(row?.textContent).toContain("Last checked"); + const manageButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Manage", + ); + act(() => manageButton?.click()); + const detailsButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Details", + ); + act(() => detailsButton?.click()); + + expect(document.body.textContent).toContain("Last run"); + expect(document.body.textContent).toContain("Never"); }); it("submits a new cron expression from the edit dialog", () => { @@ -139,7 +169,11 @@ describe("AgentJobsTab blocked automation", () => { root.render(); }); - const editButton = Array.from(container.querySelectorAll("button")).find( + const manageButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Manage", + ); + act(() => manageButton?.click()); + const editButton = [...document.body.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Edit", ); expect(editButton).toBeDefined(); @@ -147,6 +181,12 @@ describe("AgentJobsTab blocked automation", () => { act(() => { editButton?.click(); }); + const advancedButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === "Advanced"); + act(() => { + advancedButton?.click(); + }); const input = document.querySelector( "#automation-schedule", diff --git a/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx b/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx index e36fa60d90..f01f98d6d6 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx @@ -5,39 +5,25 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const jobMocks = vi.hoisted(() => ({ - manageAutomation: { - org: vi.fn(), - user: vi.fn(), - }, + manageAutomation: vi.fn(), useRunAutomationNow: vi.fn(), + useAutomationEvents: vi.fn(), useAutomations: vi.fn(), useManageAutomation: vi.fn(), useManageRecurringJob: vi.fn(), - useRecurringJobs: vi.fn(), })); vi.mock("./use-jobs.js", () => ({ + useAutomationEvents: jobMocks.useAutomationEvents, + useAutomationAccountSearch: () => ({ data: [], isFetching: false }), useAutomations: jobMocks.useAutomations, useManageAutomation: jobMocks.useManageAutomation, useManageRecurringJob: jobMocks.useManageRecurringJob, - useRecurringJobs: jobMocks.useRecurringJobs, useRunAutomationNow: jobMocks.useRunAutomationNow, })); -vi.mock("../AgentAskPopover.js", () => ({ - AgentAskPopover: ({ - context, - label, - title, - }: { - context: string; - label?: string; - title: string; - }) => ( - - ), +vi.mock("../org/hooks.js", () => ({ + useOrg: () => ({ data: { orgId: "org-1", orgName: "Acme" } }), })); vi.mock("../i18n.js", () => ({ @@ -58,28 +44,66 @@ vi.mock("../i18n.js", () => ({ }, })); -import { - AgentJobsTab, - organizationAutomationCreationContext, -} from "./AgentJobsTab.js"; +import { AgentJobsTab } from "./AgentJobsTab.js"; +import type { Automation } from "./use-jobs.js"; function queryResult(data: T) { - return { - data, - error: null, - isLoading: false, - }; + return { data, error: null, isLoading: false }; } function mutationResult(mutate: ReturnType = vi.fn()) { + return { error: null, isPending: false, mutate }; +} + +function ownerAutomation(patch: Partial = {}): Automation { return { - error: null, - isPending: false, - mutate, + id: "event-automation", + resourceId: "event-automation", + name: "new-lead-alert", + path: "jobs/new-lead-alert.md", + scope: "organization", + classification: "automation", + triggerType: "event", + event: "lead.created", + schedule: null, + timezone: null, + scheduleDescription: null, + condition: null, + body: "Alert the sales team.", + enabled: true, + lastRun: null, + lastCheck: null, + lastStatus: null, + lastError: null, + nextRun: null, + createdBy: "owner@example.com", + model: null, + mcpTools: [], + originScopeId: null, + deliveryPlatform: null, + deliveryDestination: null, + deliveryThreadRef: null, + deliveryTenantId: null, + canUpdate: true, + effectiveRole: "owner", + capabilities: { + canEdit: true, + canOperate: true, + canDelete: true, + canManageSharing: true, + }, + sharing: { + source: "explicit", + visibility: "organization", + organizationId: "org-1", + grantCount: 0, + }, + creator: { email: "owner@example.com", label: "owner@example.com" }, + ...patch, }; } -describe("AgentJobsTab organization automations", () => { +describe("AgentJobsTab unified automations list", () => { let container: HTMLDivElement; let root: Root; @@ -89,62 +113,12 @@ describe("AgentJobsTab organization automations", () => { document.body.appendChild(container); root = createRoot(container); - jobMocks.useRecurringJobs.mockImplementation((scope: "user" | "org") => - queryResult( - scope === "org" - ? [ - { - id: "legacy-scheduled", - name: "weekly-report", - path: "jobs/weekly-report.md", - scope: "organization", - schedule: "0 9 * * 1", - scheduleDescription: "Every Monday", - instructions: "Send the weekly report.", - enabled: true, - lastRun: null, - lastStatus: null, - lastError: null, - nextRun: null, - createdBy: "owner@example.com", - mcpTools: [], - canUpdate: true, - }, - ] - : [], - ), - ); - jobMocks.useAutomations.mockImplementation((scope: "user" | "org") => - queryResult( - scope === "org" - ? [ - { - id: "event-automation", - name: "new-lead-alert", - path: "jobs/new-lead-alert.md", - scope: "organization", - triggerType: "event", - event: "lead.created", - schedule: null, - scheduleDescription: null, - condition: null, - body: "Alert the sales team.", - enabled: true, - lastRun: null, - lastStatus: null, - lastError: null, - nextRun: null, - createdBy: "owner@example.com", - canUpdate: true, - }, - ] - : [], - ), - ); + jobMocks.useAutomations.mockReturnValue(queryResult([ownerAutomation()])); + jobMocks.useAutomationEvents.mockReturnValue(queryResult([])); jobMocks.useManageRecurringJob.mockReturnValue(mutationResult()); jobMocks.useRunAutomationNow.mockReturnValue(mutationResult()); - jobMocks.useManageAutomation.mockImplementation((scope: "user" | "org") => - mutationResult(jobMocks.manageAutomation[scope]), + jobMocks.useManageAutomation.mockReturnValue( + mutationResult(jobMocks.manageAutomation), ); }); @@ -155,65 +129,147 @@ describe("AgentJobsTab organization automations", () => { vi.unstubAllGlobals(); }); - it("shows scheduled and event-triggered organization automations", () => { + it("shows one unified list with no Personal/Organization sections", () => { act(() => { - root.render(); + root.render(); }); - expect(jobMocks.useAutomations).toHaveBeenCalledWith("org"); - expect(container.textContent).toContain("weekly report"); expect(container.textContent).toContain("new lead alert"); - expect(container.textContent).toContain("On lead.created"); - expect(container.textContent).toContain( - "Scheduled and event-triggered automations shared with this organization.", - ); - expect(container.textContent).not.toContain("personal today"); + expect(container.querySelectorAll("section").length).toBe(0); + expect(container.textContent).toContain("Organization"); + expect(container.textContent).not.toContain("Personal automations"); }); - it("routes organization event updates through the organization mutation", () => { + it("labels manual automations as on demand", () => { + jobMocks.useAutomations.mockReturnValue( + queryResult([ + ownerAutomation({ + triggerType: "manual", + event: null, + schedule: null, + timezone: null, + }), + ]), + ); + act(() => { - root.render(); + root.render(); }); - const eventRow = Array.from(container.querySelectorAll("article")).find( - (row) => row.textContent?.includes("new lead alert"), + expect(container.textContent).toContain("On demand"); + expect(container.textContent).toContain( + "Runs only when started on demand.", ); - const pauseButton = Array.from( - eventRow?.querySelectorAll("button") ?? [], - ).find((button) => button.textContent?.includes("Pause")); + }); + + it("routes pause/resume through the resourceId-first mutation", () => { + act(() => { + root.render(); + }); + const row = container.querySelector("article"); + const toggle = row?.querySelector('[role="switch"]'); act(() => { - pauseButton?.click(); + (toggle as HTMLButtonElement | null)?.click(); }); - expect(jobMocks.manageAutomation.org).toHaveBeenCalledWith( + expect(jobMocks.manageAutomation).toHaveBeenCalledWith( { operation: "update", - name: "new-lead-alert", - scope: "organization", + resourceId: "event-automation", enabled: false, }, undefined, ); - expect(jobMocks.manageAutomation.user).not.toHaveBeenCalled(); }); - it("creates organization automations through the scoped automation tool", () => { + it("hides mutating controls for a View-only shared automation", () => { + jobMocks.useAutomations.mockReturnValue( + queryResult([ + ownerAutomation({ + effectiveRole: "view", + capabilities: { + canEdit: false, + canOperate: false, + canDelete: false, + canManageSharing: false, + }, + sharing: { + source: "explicit", + visibility: "shared", + organizationId: null, + grantCount: 1, + }, + }), + ]), + ); act(() => { - root.render(); + root.render(); }); - const orgCreationButton = Array.from( - container.querySelectorAll("[data-creation-context]"), - ).find((button) => - button - .getAttribute("data-creation-context") - ?.includes("scope=organization"), + expect(container.textContent).toContain("Shared with you · View"); + expect(container.querySelector('[role="switch"]')).toBeNull(); + + const manageButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Manage", + ); + act(() => manageButton?.click()); + const menuButtons = [...document.body.querySelectorAll("button")].map( + (button) => button.textContent?.trim(), ); + expect(menuButtons).toContain("Details"); + expect(menuButtons).not.toContain("Edit"); + expect(menuButtons).not.toContain("Delete"); + expect(menuButtons).not.toContain("Run now"); + }); + + it("closes the full editor after an explicit automation update succeeds", () => { + jobMocks.useManageAutomation.mockReturnValue({ + error: null, + isPending: false, + mutate: (input: unknown, options?: { onSuccess?: () => void }) => { + jobMocks.manageAutomation(input); + options?.onSuccess?.(); + }, + }); + act(() => { + root.render(); + }); - expect(orgCreationButton).not.toBeUndefined(); - expect(organizationAutomationCreationContext()).toContain( - "manage-automations with action=define and scope=organization", + const manageButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Manage", + ); + act(() => manageButton?.click()); + const editButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Edit", + ); + act(() => editButton?.click()); + expect(document.body.textContent).toContain("Edit automation"); + + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No automation instructions field"); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(body, "Notify the account team."); + body.dispatchEvent(new Event("input", { bubbles: true })); + body.dispatchEvent(new Event("change", { bubbles: true })); + }); + const saveButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Save changes", + ); + act(() => saveButton?.click()); + + expect(jobMocks.manageAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "update", + resourceId: "event-automation", + body: "Notify the account team.", + }), ); + expect(document.body.textContent).not.toContain("Edit automation"); }); }); diff --git a/packages/core/src/client/agent-page/AgentJobsTab.tsx b/packages/core/src/client/agent-page/AgentJobsTab.tsx index 3155942079..fee124298c 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.tsx @@ -1,3 +1,4 @@ +import { Avatar, AvatarFallback } from "@agent-native/toolkit/ui/avatar"; import { Button } from "@agent-native/toolkit/ui/button"; import { IconBolt, @@ -7,14 +8,14 @@ import { IconChevronDown, IconEye, IconLoader2, + IconMail, IconPencil, - IconPlayerPause, + IconPlus, IconPlayerPlay, IconTrash, } from "@tabler/icons-react"; import { useState } from "react"; -import { AgentAskPopover } from "../AgentAskPopover.js"; import { Dialog, DialogContent, @@ -29,13 +30,13 @@ import { PopoverTrigger, } from "../components/ui/popover.js"; import { useFormatters, useT } from "../i18n.js"; -import { automationCreationContext } from "../settings/AutomationsSection.js"; import { AgentEmptyState } from "./AgentEmptyState.js"; import { AgentTabFrame } from "./AgentTabFrame.js"; import { AutomationDetailsDialog, type AutomationDetailsField, } from "./AutomationDetailsDialog.js"; +import { AutomationEditorDialog } from "./AutomationEditorDialog.js"; import { AutomationScheduleDialog } from "./AutomationScheduleDialog.js"; import type { AgentPageTabProps } from "./types.js"; import { @@ -43,88 +44,64 @@ import { useManageAutomation, useManageRecurringJob, useRunAutomationNow, - useRecurringJobs, type Automation, - type RecurringJob, } from "./use-jobs.js"; -type ListedAutomation = - | { - kind: "recurring"; - resource: RecurringJob; - triggerType: "schedule"; - } - | { - kind: "automation"; - resource: Automation; - triggerType: "event" | "schedule"; - }; - -function listRecurringJobs(jobs: RecurringJob[]): ListedAutomation[] { - return jobs.map((resource) => ({ - kind: "recurring", - resource, - triggerType: "schedule", - })); -} - -function listAutomations(automations: Automation[]): ListedAutomation[] { - return automations.map((resource) => ({ - kind: "automation", - resource, - triggerType: resource.triggerType, - })); -} - type Translate = ReturnType; -function describeTrigger(entry: ListedAutomation, t: Translate): string { - if (entry.kind === "automation" && entry.triggerType === "event") { +function describeTrigger(automation: Automation, t: Translate): string { + if (automation.triggerType === "manual") { + return t("jobs.automationManualDetails", { + defaultValue: "Runs only when started on demand.", + }); + } + if (automation.triggerType === "event") { return t("jobs.automationEventDetails", { defaultValue: "Runs when {{event}}.", - event: entry.resource.event ?? "an event fires", + event: automation.event ?? "an event fires", }); } return ( - entry.resource.scheduleDescription || - entry.resource.schedule || + automation.scheduleDescription || + automation.schedule || t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }) ); } function detailsFields( - entry: ListedAutomation, + automation: Automation, t: Translate, formatDateTime: (value: string | null) => string | null, ): AutomationDetailsField[] { - const resource = entry.resource; const unset = t("jobs.notSet", { defaultValue: "—" }); const fields: AutomationDetailsField[] = [ { label: t("jobs.status", { defaultValue: "Status" }), - value: resource.enabled + value: automation.enabled ? t("jobs.enabled", { defaultValue: "Enabled" }) : t("jobs.paused", { defaultValue: "Paused" }), }, { label: t("jobs.trigger", { defaultValue: "Trigger" }), value: - entry.triggerType === "event" - ? t("jobs.eventTrigger", { defaultValue: "Event-triggered" }) - : t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }), + automation.triggerType === "manual" + ? t("jobs.manualTrigger", { defaultValue: "On demand" }) + : automation.triggerType === "event" + ? t("jobs.eventTrigger", { defaultValue: "Event-triggered" }) + : t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }), }, ]; - if (entry.triggerType === "schedule") { + if (automation.triggerType === "schedule") { fields.push( { label: t("jobs.cronExpression", { defaultValue: "Cron expression" }), - value: resource.schedule || unset, + value: automation.schedule || unset, mono: true, }, { label: t("jobs.timezone", { defaultValue: "Timezone" }), - value: resource.timezone || unset, + value: automation.timezone || unset, }, ); } @@ -132,74 +109,119 @@ function detailsFields( fields.push( { label: t("jobs.nextRun", { defaultValue: "Next run" }), - value: formatDateTime(resource.nextRun) ?? unset, + value: formatDateTime(automation.nextRun) ?? unset, }, { label: t("jobs.lastRun", { defaultValue: "Last run" }), value: - formatDateTime(resource.lastRun) ?? + formatDateTime(automation.lastRun) ?? t("jobs.neverRan", { defaultValue: "Never" }), }, { label: t("jobs.lastChecked", { defaultValue: "Last checked" }), - value: formatDateTime(resource.lastCheck) ?? unset, + value: formatDateTime(automation.lastCheck) ?? unset, }, { label: t("jobs.lastStatus", { defaultValue: "Last status" }), - value: resource.lastStatus || unset, + value: automation.lastStatus || unset, }, { - label: t("jobs.scope", { defaultValue: "Scope" }), - value: - resource.scope === "organization" - ? t("jobs.organization", { defaultValue: "Organization" }) - : t("jobs.personal", { defaultValue: "Personal" }), + label: t("jobs.sharingLabel", { defaultValue: "Sharing" }), + value: sharingLabel(automation, t), }, { label: t("jobs.createdBy", { defaultValue: "Created by" }), - value: resource.createdBy || unset, + value: automation.createdBy || unset, }, ); - if (entry.kind === "automation") { + if (automation.classification === "automation") { fields.push({ label: t("jobs.model", { defaultValue: "Model" }), - value: entry.resource.model || unset, + value: automation.model || unset, }); } return fields; } -export function organizationAutomationCreationContext(): string { - return "The user wants to create a new organization automation. Use manage-automations with action=define and scope=organization to create it. Ask clarifying questions if needed about whether it runs on a schedule or event, any conditions, and what actions to take."; +function sharingLabel(automation: Automation, t: Translate): string { + const sharing = automation.sharing; + if (automation.effectiveRole !== "owner") { + return sharing.visibility === "organization" + ? t("jobs.sharingOrganization", { defaultValue: "Organization" }) + : automation.effectiveRole === "collaborate" + ? t("jobs.sharingBadgeSharedCollaborate", { + defaultValue: "Shared with you · Collaborate", + }) + : t("jobs.sharingBadgeSharedView", { + defaultValue: "Shared with you · View", + }); + } + if (sharing.visibility === "organization") { + return t("jobs.sharingOrganization", { defaultValue: "Organization" }); + } + if (sharing.visibility === "shared") { + return t("jobs.sharingSpecificCount", { + defaultValue: "Shared with {{count}} people", + count: sharing.grantCount, + }); + } + return t("jobs.sharingPersonal", { defaultValue: "Personal" }); +} + +function initials(label: string): string { + return label.slice(0, 2).toUpperCase(); +} + +function SharingBadge({ + automation, + t, +}: { + automation: Automation; + t: Translate; +}) { + const sharing = automation.sharing; + const grants = sharing.grants ?? []; + return ( + + {sharingLabel(automation, t)} + {automation.effectiveRole === "owner" && + sharing.visibility === "shared" && + grants.length > 0 ? ( + + {grants.slice(0, 3).map((grant) => ( + + {grant.avatar ? : null} + + {initials(grant.name || grant.email)} + + + ))} + + ) : null} + + ); } export function AgentJobsTab({ - canManageOrg = false, hideHeader = false, }: AgentPageTabProps & { hideHeader?: boolean }) { const t = useT(); const formatters = useFormatters(); - const personalJobsQuery = useRecurringJobs("user"); - const personalAutomationsQuery = useAutomations("user"); - const organizationJobsQuery = useRecurringJobs("org"); - const organizationAutomationsQuery = useAutomations("org"); - const personalJobsMutation = useManageRecurringJob("user"); - const personalAutomationsMutation = useManageAutomation("user"); - const organizationJobsMutation = useManageRecurringJob("org"); - const organizationAutomationsMutation = useManageAutomation("org"); + const automationsQuery = useAutomations(); + const automationsMutation = useManageAutomation(); + const jobsMutation = useManageRecurringJob(); const runAutomationMutation = useRunAutomationNow(); - const [deleteTarget, setDeleteTarget] = useState( - null, - ); - const [detailsTarget, setDetailsTarget] = useState( - null, - ); - const [scheduleTarget, setScheduleTarget] = useState( - null, - ); - const [runTarget, setRunTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [detailsTarget, setDetailsTarget] = useState(null); + const [scheduleTarget, setScheduleTarget] = useState(null); + const [editorTarget, setEditorTarget] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [runTarget, setRunTarget] = useState(null); const formatDateTime = (value: string | null) => { if (!value || Number.isNaN(new Date(value).getTime())) return null; @@ -211,494 +233,301 @@ export function AgentJobsTab({ }); }; - const personalEntries = [ - ...listRecurringJobs(personalJobsQuery.data ?? []), - ...listAutomations(personalAutomationsQuery.data ?? []), - ]; - const organizationEntries = [ - ...listRecurringJobs(organizationJobsQuery.data ?? []), - ...listAutomations(organizationAutomationsQuery.data ?? []), - ]; + const automations = automationsQuery.data ?? []; const mutationPending = - personalJobsMutation.isPending || - personalAutomationsMutation.isPending || - organizationJobsMutation.isPending || - organizationAutomationsMutation.isPending; + automationsMutation.isPending || jobsMutation.isPending; - const mutateEntry = ( - entry: ListedAutomation, - operation: "update" | "delete", - patch?: { enabled?: boolean; schedule?: string }, + const mutateAutomation = ( + automation: Automation, + patch: { enabled?: boolean; schedule?: string; timezone?: string }, onSuccess?: () => void, ) => { - const input = { - operation, - name: entry.resource.name, - scope: entry.resource.scope, - ...patch, - }; const options = onSuccess ? { onSuccess } : undefined; + if (automation.classification === "automation") { + automationsMutation.mutate( + { operation: "update", resourceId: automation.resourceId, ...patch }, + options, + ); + } else { + jobsMutation.mutate( + { operation: "update", resourceId: automation.resourceId, ...patch }, + options, + ); + } + }; - if (entry.kind === "automation") { - const mutation = - entry.resource.scope === "organization" - ? organizationAutomationsMutation - : personalAutomationsMutation; - mutation.mutate(input, options); - } else if (entry.resource.scope === "organization") { - organizationJobsMutation.mutate(input, options); + const deleteAutomation = (automation: Automation, onSuccess?: () => void) => { + const options = onSuccess ? { onSuccess } : undefined; + if (automation.classification === "automation") { + automationsMutation.mutate( + { operation: "delete", resourceId: automation.resourceId }, + options, + ); } else { - personalJobsMutation.mutate(input, options); + jobsMutation.mutate( + { operation: "delete", resourceId: automation.resourceId }, + options, + ); } }; - const renderSection = ({ - title, - description, - entries, - loading, - errors, - organization = false, - }: { - title: string; - description: string; - entries: ListedAutomation[]; - loading: boolean; - errors: unknown[]; - organization?: boolean; - }) => ( -
-
-
-

- {title} -

-

- {description} -

-
- {organization ? ( -
- {!canManageOrg ? ( - - {t("jobs.organizationMemberNote", { - defaultValue: "You can manage automations you created.", - })} - - ) : null} - -
+ const openEditor = (automation: Automation | null) => { + setEditorTarget(automation); + setEditorOpen(true); + }; + + const mutationError = + automationsMutation.error || + jobsMutation.error || + runAutomationMutation.error; + + const newAutomationButton = ( + + ); + + return ( + +
+ {hideHeader ? ( +
{newAutomationButton}
) : null} -
- {errors.length > 0 ? ( -

- {t("jobs.loadError", { - defaultValue: "Could not load all automations.", - })} -

- ) : null} + {automationsQuery.error ? ( +

+ {t("jobs.loadError", { + defaultValue: "Could not load all automations.", + })} +

+ ) : null} - {loading && entries.length === 0 ? ( -
- - {t("jobs.loading", { defaultValue: "Loading…" })} -
- ) : entries.length === 0 && errors.length === 0 ? ( - - ) - } - variant="card" - /> - ) : ( -
-
- {entries.map((entry) => { - const resource = entry.resource; - const lastRun = formatDateTime(resource.lastRun); - const lastCheck = formatDateTime(resource.lastCheck); - const nextRun = formatDateTime(resource.nextRun); - const triggerDescription = - entry.kind === "automation" && entry.triggerType === "event" - ? t("jobs.automationEventTrigger", { - defaultValue: "On {{event}}", - event: entry.resource.event ?? "event", + {automationsQuery.isLoading && automations.length === 0 ? ( +
+ + {t("jobs.loading", { defaultValue: "Loading…" })} +
+ ) : automations.length === 0 && !automationsQuery.error ? ( + + ) : ( +
+
+ {automations.map((automation) => { + const lastCheck = formatDateTime(automation.lastCheck); + const isEventTrigger = automation.triggerType === "event"; + const isEmailTrigger = + isEventTrigger && + automation.event === "mail.message.received"; + const isManualTrigger = automation.triggerType === "manual"; + const triggerDescription = isManualTrigger + ? t("jobs.automationManualDetails", { + defaultValue: "Runs only when started on demand.", }) - : resource.scheduleDescription || - resource.schedule || - t("jobs.scheduledTrigger", { - defaultValue: "Scheduled", - }); - const instructions = - entry.kind === "automation" - ? entry.resource.body - : entry.resource.instructions; + : isEventTrigger + ? t("jobs.automationEventTrigger", { + defaultValue: "On {{event}}", + event: automation.event ?? "event", + }) + : automation.scheduleDescription || + automation.schedule || + t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }); - return ( -
-
-
- {entry.triggerType === "event" ? ( - - ) : ( - - )} -
-
-
-

- {resource.name.replace(/-/g, " ")} -

- - {entry.triggerType === "event" - ? t("jobs.eventTrigger", { - defaultValue: "Event-triggered", - }) - : t("jobs.scheduledTrigger", { - defaultValue: "Scheduled", - })} - - - {resource.enabled - ? t("jobs.enabled", { defaultValue: "Enabled" }) - : t("jobs.paused", { defaultValue: "Paused" })} - - {resource.lastStatus ? ( + return ( +
+
+
+ {isManualTrigger ? ( + + ) : isEmailTrigger ? ( + + ) : isEventTrigger ? ( + + ) : ( + + )} +
+
+
+

+ {automation.name.replace(/-/g, " ")} +

- {resource.lastStatus} + {isManualTrigger + ? t("jobs.manualTrigger", { + defaultValue: "On demand", + }) + : isEmailTrigger + ? t("jobs.emailTrigger", { + defaultValue: "Email received", + }) + : isEventTrigger + ? t("jobs.eventTrigger", { + defaultValue: "Event-triggered", + }) + : t("jobs.scheduledTrigger", { + defaultValue: "Scheduled", + })} - ) : null} -
-

- {triggerDescription} -

-

{instructions}

- {lastRun || nextRun || lastCheck ? ( -
- {nextRun ? ( - - {t("jobs.nextRun", { defaultValue: "Next run" })}:{" "} - {nextRun} + + {automation.enabled + ? t("jobs.enabled", { defaultValue: "Enabled" }) + : t("jobs.paused", { defaultValue: "Paused" })} + + + {automation.lastStatus ? ( + + {automation.lastStatus} ) : null} - - {t("jobs.lastRun", { defaultValue: "Last run" })}:{" "} - {lastRun ?? - t("jobs.neverRan", { defaultValue: "Never" })} - - {!lastRun && lastCheck ? ( +
+

+ {triggerDescription} +

+

{automation.body}

+ {lastCheck ? ( +
{t("jobs.lastChecked", { defaultValue: "Last checked", })} : {lastCheck} - ) : null} -
- ) : null} - {resource.lastError ? ( -

- - - {resource.lastError} - -

- ) : null} -
-
- {resource.canUpdate ? ( - - ) : null} - - - - - +
+ ) : null} + {automation.lastError ? ( +

+ + + {automation.lastError} + +

+ ) : null} +
+
+ {automation.capabilities.canOperate ? ( - {resource.canUpdate ? ( - <> + ) : null} + + + + + + + {automation.capabilities.canOperate ? ( + ) : null} + {automation.capabilities.canEdit ? ( + - {entry.triggerType === "schedule" ? ( - - ) : null} + ) : null} + {automation.capabilities.canDelete ? ( - - ) : null} - - -
-
- - {resource.canUpdate ? ( - <> - - {entry.triggerType === "schedule" ? ( - - ) : null} - - - - ) : null} + ) : null} + + +
-
-
- ); - })} -
-
- )} -
- ); - - const mutationError = - personalJobsMutation.error || - personalAutomationsMutation.error || - organizationJobsMutation.error || - organizationAutomationsMutation.error || - runAutomationMutation.error; - - return ( - - } - > -
- {hideHeader ? ( -
- + ); })} - /> +
- ) : null} - {renderSection({ - title: t("jobs.personal", { defaultValue: "Personal" }), - description: t("jobs.personalDescription", { - defaultValue: - "Scheduled and event-triggered automations that run for you.", - }), - entries: personalEntries, - loading: - personalJobsQuery.isLoading || personalAutomationsQuery.isLoading, - errors: [ - personalJobsQuery.error, - personalAutomationsQuery.error, - ].filter(Boolean), - })} -
- {renderSection({ - title: t("jobs.organization", { defaultValue: "Organization" }), - description: t("jobs.organizationDescription", { - defaultValue: - "Scheduled and event-triggered automations shared with this organization.", - }), - entries: organizationEntries, - loading: - organizationJobsQuery.isLoading || - organizationAutomationsQuery.isLoading, - errors: [ - organizationJobsQuery.error, - organizationAutomationsQuery.error, - ].filter(Boolean), - organization: true, - })} -
+ )} {mutationError ? (

{mutationError.message || @@ -746,9 +575,7 @@ export function AgentJobsTab({ disabled={mutationPending} onClick={() => { if (!deleteTarget) return; - mutateEntry(deleteTarget, "delete", undefined, () => - setDeleteTarget(null), - ); + deleteAutomation(deleteTarget, () => setDeleteTarget(null)); }} > {mutationPending ? ( @@ -800,10 +627,7 @@ export function AgentJobsTab({ onClick={() => { if (!runTarget) return; runAutomationMutation.mutate( - { - name: runTarget.resource.name, - scope: runTarget.resource.scope, - }, + { resourceId: runTarget.resourceId }, { onSuccess: () => setRunTarget(null) }, ); }} @@ -822,24 +646,18 @@ export function AgentJobsTab({ {detailsTarget ? ( formatDateTime(new Date(value).toISOString()) ?? String(value) } @@ -850,19 +668,33 @@ export function AgentJobsTab({ {scheduleTarget ? ( setScheduleTarget(null)} onSave={(next) => - mutateEntry(scheduleTarget, "update", next, () => + mutateAutomation(scheduleTarget, next, () => setScheduleTarget(null), ) } /> ) : null} + + setEditorOpen(false)} + onSave={(input) => { + automationsMutation.mutate(input, { + onSuccess: () => setEditorOpen(false), + }); + }} + /> ); } diff --git a/packages/core/src/client/agent-page/AutomationDetailsDialog.tsx b/packages/core/src/client/agent-page/AutomationDetailsDialog.tsx index 39bbc1204d..e78e33131b 100644 --- a/packages/core/src/client/agent-page/AutomationDetailsDialog.tsx +++ b/packages/core/src/client/agent-page/AutomationDetailsDialog.tsx @@ -8,7 +8,7 @@ import { DialogTitle, } from "../components/ui/dialog.js"; import { useT } from "../i18n.js"; -import { useAutomationRuns, type JobsScope } from "./use-jobs.js"; +import { useAutomationRuns } from "./use-jobs.js"; export interface AutomationDetailsField { label: string; @@ -18,6 +18,7 @@ export interface AutomationDetailsField { export interface AutomationDetailsDialogProps { open: boolean; + resourceId: string; name: string; triggerSummary: string; fields: AutomationDetailsField[]; @@ -25,7 +26,6 @@ export interface AutomationDetailsDialogProps { instructions: string; mcpTools: string[]; lastError: string | null; - scope: JobsScope; formatTimestamp: (value: number) => string; onClose: () => void; } @@ -45,6 +45,7 @@ function RunStatusDot({ status }: { status: string }) { } export function AutomationDetailsDialog({ + resourceId, open, name, triggerSummary, @@ -53,12 +54,11 @@ export function AutomationDetailsDialog({ instructions, mcpTools, lastError, - scope, formatTimestamp, onClose, }: AutomationDetailsDialogProps) { const t = useT(); - const runsQuery = useAutomationRuns(scope, open ? name : null, open); + const runsQuery = useAutomationRuns(open ? { resourceId } : null, open); const runs = runsQuery.data ?? []; return ( diff --git a/packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx b/packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx new file mode 100644 index 0000000000..791e487cc8 --- /dev/null +++ b/packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx @@ -0,0 +1,514 @@ +/* @vitest-environment jsdom */ + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + events: [] as Array<{ + name: string; + description: string; + payloadSchema: null; + example: null; + }>, + openAgentSettings: vi.fn(), + org: { orgId: null as string | null, orgName: null as string | null }, +})); + +vi.mock("./use-jobs.js", () => ({ + useAutomationEvents: () => ({ + data: mocks.events, + error: null, + isLoading: false, + }), + useAutomationAccountSearch: () => ({ data: [], isFetching: false }), +})); + +vi.mock("../org/hooks.js", () => ({ + useOrg: () => ({ data: mocks.org }), +})); + +vi.mock("../CommandMenu.js", () => ({ + openAgentSettings: mocks.openAgentSettings, +})); + +vi.mock("../i18n.js", () => ({ + useT: + () => + ( + key: string, + options?: Record, + ): string => { + let result = String(options?.defaultValue ?? key); + for (const [name, value] of Object.entries(options ?? {})) { + result = result.replaceAll(`{{${name}}}`, String(value)); + } + return result; + }, +})); + +vi.mock("./TimezoneSelect.js", () => ({ + browserTimezone: () => "UTC", + TimezoneSelect: ({ + id, + value, + disabled, + onChange, + }: { + id?: string; + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), +})); + +import { AutomationEditorDialog } from "./AutomationEditorDialog.js"; +import type { Automation } from "./use-jobs.js"; + +function findButton(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim().startsWith(text), + ); + if (!button) throw new Error(`No button named "${text}"`); + return button as HTMLButtonElement; +} + +function changeValue( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + value: string, +) { + const prototype = + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : element instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (!setter) throw new Error("Value setter unavailable"); + act(() => { + setter.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function input(id: string): HTMLInputElement { + const element = document.querySelector(`#${id}`); + if (!element) throw new Error(`No input #${id}`); + return element; +} + +function click(text: string) { + act(() => findButton(text).click()); +} + +function explicitAutomation(patch: Partial = {}): Automation { + return { + id: "automation-1", + resourceId: "automation-1", + name: "customer-digest", + path: "jobs/customer-digest.md", + scope: "personal", + classification: "automation", + triggerType: "schedule", + event: null, + schedule: " */15 * * * * ", + timezone: "UTC", + scheduleDescription: null, + condition: null, + body: "Summarize customer updates.", + enabled: true, + lastRun: null, + lastCheck: null, + lastStatus: null, + lastError: null, + nextRun: null, + createdBy: null, + model: null, + mcpTools: [], + originScopeId: null, + deliveryPlatform: null, + deliveryDestination: null, + deliveryThreadRef: null, + deliveryTenantId: null, + canUpdate: true, + effectiveRole: "owner", + capabilities: { + canEdit: true, + canOperate: true, + canDelete: true, + canManageSharing: true, + }, + sharing: { + source: "explicit", + visibility: "private", + organizationId: null, + grantCount: 0, + }, + creator: { email: "owner@example.com", label: "owner@example.com" }, + ...patch, + }; +} + +describe("AutomationEditorDialog", () => { + let container: HTMLDivElement; + let root: Root; + const onSave = vi.fn(); + const onCancel = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Element.prototype.scrollIntoView = () => {}; + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + mocks.events = [ + { + name: "issue.created", + description: "A new issue was created.", + payloadSchema: null, + example: null, + }, + { + name: "mail.message.received", + description: "A new email arrived.", + payloadSchema: null, + example: null, + }, + ]; + mocks.openAgentSettings.mockReset(); + mocks.org.orgId = null; + mocks.org.orgName = null; + onSave.mockReset(); + onCancel.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + function render( + props: Partial> = {}, + ) { + act(() => { + root.render( + , + ); + }); + } + + function fillRequired() { + changeValue(input("automation-name"), "Customer digest"); + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No instructions field"); + changeValue(body, "Summarize customer updates."); + } + + it.each(["personal", "organization"] as const)( + "fixes create payloads to the %s opener scope and defaults sharing to Personal", + (scope) => { + render({ scope }); + fillRequired(); + click("On demand"); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith({ + operation: "create", + name: "Customer digest", + scope, + triggerType: "manual", + body: "Summarize customer updates.", + sharing: { kind: "personal" }, + }); + expect(document.body.textContent).toContain( + `fixed to the ${scope} scope`, + ); + }, + ); + + it("edits an existing manual automation without rejected trigger fields", () => { + render({ + automation: explicitAutomation({ + triggerType: "manual", + event: null, + schedule: null, + timezone: null, + condition: null, + }), + }); + + click("Save changes"); + + expect(onSave).toHaveBeenCalledWith({ + operation: "update", + resourceId: "automation-1", + triggerType: "manual", + body: "Summarize customer updates.", + sharing: { kind: "personal" }, + }); + const payload = onSave.mock.calls[0]?.[0] as Record; + for (const field of [ + "event", + "schedule", + "timezone", + "condition", + "name", + "scope", + ]) { + expect(Object.hasOwn(payload, field)).toBe(false); + } + }); + + it("switches an existing event to manual without stale trigger fields", () => { + render({ + automation: explicitAutomation({ + triggerType: "event", + event: "issue.created", + schedule: null, + timezone: null, + condition: "Only customer-reported issues", + }), + }); + + click("On demand"); + click("Save changes"); + + const payload = onSave.mock.calls[0]?.[0] as Record; + expect(payload).toEqual({ + operation: "update", + resourceId: "automation-1", + triggerType: "manual", + body: "Summarize customer updates.", + sharing: { kind: "personal" }, + }); + for (const field of ["event", "schedule", "timezone", "condition"]) { + expect(Object.hasOwn(payload, field)).toBe(false); + } + }); + + it("submits and preserves an advanced cron schedule", () => { + render(); + fillRequired(); + click("Advanced"); + changeValue(input("automation-schedule"), " */15 * * * * "); + const zone = document.querySelector( + "#automation-timezone", + ); + if (!zone) throw new Error("No timezone field"); + changeValue(zone, "Europe/Paris"); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + triggerType: "schedule", + schedule: " */15 * * * * ", + timezone: "Europe/Paris", + }), + ); + }); + + it("submits a selected registered app event and optional condition", () => { + render(); + fillRequired(); + click("App event"); + click("Select an event"); + const eventOption = [...document.querySelectorAll("[cmdk-item]")].find( + (item) => item.textContent?.includes("issue.created"), + ); + if (!eventOption) throw new Error("No issue.created event option"); + act(() => + eventOption.dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + changeValue( + input("automation-event-condition"), + "Only customer-reported issues", + ); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + triggerType: "event", + event: "issue.created", + condition: "Only customer-reported issues", + }), + ); + }); + + it("submits the email event with explicit natural-language field filters", () => { + render({ scope: "organization" }); + fillRequired(); + click("Email received"); + changeValue(input("automation-email-from"), "alerts@example.test"); + changeValue(input("automation-email-to"), "team@example.test"); + changeValue(input("automation-email-subject"), "Urgent"); + changeValue( + input("automation-email-condition"), + "Only messages with attachments", + ); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "organization", + triggerType: "event", + event: "mail.message.received", + condition: + 'The event field from must contain "alerts@example.test".\n' + + 'The event field to must contain "team@example.test".\n' + + 'The event field subject must contain "Urgent".\n' + + "Also: Only messages with attachments", + }), + ); + }); + + it("keeps Email received visible but unavailable and opens connection settings", () => { + mocks.events = mocks.events.filter( + (event) => event.name !== "mail.message.received", + ); + render(); + + const email = findButton( + "Email receivedConnect Mail to use email-triggered automations.", + ); + expect(email.getAttribute("aria-disabled")).toBe("true"); + act(() => email.click()); + expect(document.querySelector("#automation-email-from")).toBeNull(); + + click("Open connections"); + expect(mocks.openAgentSettings).toHaveBeenCalledWith("connections"); + }); + + it("retains entered values and shows a service error without closing", () => { + render({ error: null }); + fillRequired(); + click("On demand"); + click("Create automation"); + + render({ error: "The automation name is already in use." }); + + expect(input("automation-name").value).toBe("Customer digest"); + expect(document.body.textContent).toContain( + "The automation name is already in use.", + ); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("edits an explicit automation with an immutable name and preserved cron", () => { + const automation = explicitAutomation(); + render({ automation }); + + expect(input("automation-name").readOnly).toBe(true); + expect(input("automation-name").value).toBe("customer digest"); + expect(input("automation-schedule").value).toBe(" */15 * * * * "); + + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No instructions field"); + changeValue(body, "Create a concise customer summary."); + click("Save changes"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "update", + resourceId: "automation-1", + triggerType: "schedule", + schedule: " */15 * * * * ", + body: "Create a concise customer summary.", + }), + ); + }); + + it("restores specialized email fields while editing", () => { + render({ + automation: explicitAutomation({ + triggerType: "event", + event: "mail.message.received", + schedule: null, + timezone: null, + condition: + 'The event field from must contain "billing@example.test".\nAlso: Only unread messages', + }), + }); + + expect(input("automation-email-from").value).toBe("billing@example.test"); + expect(input("automation-email-condition").value).toBe( + "Only unread messages", + ); + }); + + it("submits Organization sharing with the current organization id", () => { + mocks.org.orgId = "org-1"; + mocks.org.orgName = "Acme"; + render(); + fillRequired(); + click("On demand"); + const organizationRadio = document.querySelector( + '[role="radio"][value="organization"]', + ); + if (!organizationRadio) throw new Error("No Organization sharing radio"); + act(() => organizationRadio.click()); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + sharing: { kind: "organization", organizationId: "org-1" }, + }), + ); + }); + + it("shows a read-only sharing summary for a collaborator without sharing controls", () => { + render({ + automation: explicitAutomation({ + effectiveRole: "collaborate", + capabilities: { + canEdit: true, + canOperate: true, + canDelete: false, + canManageSharing: false, + }, + sharing: { + source: "explicit", + visibility: "shared", + organizationId: null, + grantCount: 2, + }, + }), + }); + + expect(document.body.textContent).toContain( + "Only the owner can change sharing.", + ); + click("Save changes"); + + const payload = onSave.mock.calls[0]?.[0] as Record; + expect(Object.hasOwn(payload, "sharing")).toBe(false); + }); +}); diff --git a/packages/core/src/client/agent-page/AutomationEditorDialog.tsx b/packages/core/src/client/agent-page/AutomationEditorDialog.tsx new file mode 100644 index 0000000000..84ab6b9bf6 --- /dev/null +++ b/packages/core/src/client/agent-page/AutomationEditorDialog.tsx @@ -0,0 +1,516 @@ +import { Button } from "@agent-native/toolkit/ui/button"; +import { Input } from "@agent-native/toolkit/ui/input"; +import { Textarea } from "@agent-native/toolkit/ui/textarea"; +import { IconLoader2 } from "@tabler/icons-react"; +import { useEffect, useMemo, useState } from "react"; + +import { openAgentSettings } from "../CommandMenu.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog.js"; +import { useT } from "../i18n.js"; +import { useOrg } from "../org/hooks.js"; +import { + friendlyAutomationScheduleToCron, + isValidAutomationSchedule, + DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, +} from "./automation-schedule-fields.js"; +import { + AutomationEmailFields, + AutomationEventFields, + AutomationTriggerCards, + type EditorTrigger, + type EmailFilters, +} from "./AutomationEditorTriggerFields.js"; +import { AutomationScheduleFields } from "./AutomationScheduleFields.js"; +import { + automationSharingIsValid, + automationSharingRequiresAcknowledgement, + automationSharingStateFromSummary, + defaultAutomationSharingState, + AutomationSharingFields, + AutomationSharingSummaryView, + type AutomationSharingState, +} from "./AutomationSharingFields.js"; +import { browserTimezone } from "./TimezoneSelect.js"; +import { + useAutomationEvents, + type Automation, + type AutomationSharingSubmission, + type ManageAutomationInput, +} from "./use-jobs.js"; + +const EMAIL_EVENT = "mail.message.received"; + +export interface AutomationEditorDialogProps { + open: boolean; + scope: "personal" | "organization"; + automation?: Automation | null; + saving: boolean; + error?: string | null; + onCancel: () => void; + onSave: (input: ManageAutomationInput) => void; +} + +function editorTrigger(automation?: Automation | null): EditorTrigger { + if (!automation) return "schedule"; + if (automation.triggerType === "manual") return "manual"; + if (automation.triggerType === "schedule") return "schedule"; + return automation.event === EMAIL_EVENT ? "email" : "event"; +} + +function parseEmailCondition(condition: string | null): EmailFilters { + const filters: EmailFilters = { + from: "", + to: "", + subject: "", + additional: "", + }; + if (!condition) return filters; + + const unmatched: string[] = []; + for (const line of condition.split("\n")) { + const fieldMatch = + /^The event field (from|to|subject) must contain ("(?:[^"\\]|\\.)*")\.$/.exec( + line, + ); + if (fieldMatch) { + try { + filters[fieldMatch[1] as keyof Omit] = + JSON.parse(fieldMatch[2]) as string; + continue; + } catch { + unmatched.push(line); + continue; + } + } + const additionalMatch = /^Also: (.*)$/.exec(line); + unmatched.push(additionalMatch?.[1] ?? line); + } + filters.additional = unmatched.filter(Boolean).join("\n"); + return filters; +} + +function buildSharingSubmission( + state: AutomationSharingState, + orgId: string | null, +): AutomationSharingSubmission { + if (state.mode === "organization") { + return { kind: "organization", organizationId: orgId || "" }; + } + if (state.mode === "specific") { + return { + kind: "specific", + organizationId: orgId, + grants: state.grants.map((grant) => ({ + email: grant.email, + role: grant.role, + })), + }; + } + return { kind: "personal" }; +} + +function emailCondition(filters: EmailFilters): string | null { + const lines: string[] = []; + for (const field of ["from", "to", "subject"] as const) { + const value = filters[field].trim(); + if (value) { + lines.push( + `The event field ${field} must contain ${JSON.stringify(value)}.`, + ); + } + } + if (filters.additional.trim()) + lines.push(`Also: ${filters.additional.trim()}`); + return lines.length ? lines.join("\n") : null; +} + +export function AutomationEditorDialog({ + open, + scope, + automation, + saving, + error, + onCancel, + onSave, +}: AutomationEditorDialogProps) { + const t = useT(); + const eventsQuery = useAutomationEvents(); + const org = useOrg(); + const orgId = org.data?.orgId ?? null; + const orgName = org.data?.orgName ?? null; + const isOwner = !automation || automation.capabilities.canManageSharing; + const defaultSchedule = friendlyAutomationScheduleToCron( + DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + ); + const [name, setName] = useState(""); + const [sharing, setSharing] = useState( + defaultAutomationSharingState(), + ); + const [trigger, setTrigger] = useState("schedule"); + const [schedule, setSchedule] = useState(defaultSchedule); + const [timezone, setTimezone] = useState(browserTimezone()); + const [eventName, setEventName] = useState(""); + const [eventCondition, setEventCondition] = useState(""); + const [emailFilters, setEmailFilters] = useState(() => + parseEmailCondition(null), + ); + const [body, setBody] = useState(""); + const [eventPickerOpen, setEventPickerOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (!open) return; + const nextTrigger = editorTrigger(automation); + setName(automation?.name.replace(/-/g, " ") ?? ""); + setTrigger(nextTrigger); + setSchedule(automation?.schedule ?? defaultSchedule); + setTimezone(automation?.timezone ?? browserTimezone()); + setEventName( + automation?.triggerType === "event" && automation.event !== EMAIL_EVENT + ? (automation.event ?? "") + : "", + ); + setEventCondition( + automation?.triggerType === "event" && automation.event !== EMAIL_EVENT + ? (automation.condition ?? "") + : "", + ); + setEmailFilters( + parseEmailCondition( + automation?.triggerType === "event" && automation.event === EMAIL_EVENT + ? automation.condition + : null, + ), + ); + setBody(automation?.body ?? ""); + setEventPickerOpen(false); + setSubmitted(false); + setSharing( + automation + ? automationSharingStateFromSummary(automation.sharing) + : defaultAutomationSharingState(), + ); + }, [automation, defaultSchedule, open, scope]); + + const events = eventsQuery.data ?? []; + const emailAvailable = events.some((event) => event.name === EMAIL_EVENT); + const nameInvalid = !automation && !name.trim(); + const bodyInvalid = !body.trim(); + const eventInvalid = trigger === "event" && !eventName; + const scheduleInvalid = + trigger === "schedule" && !isValidAutomationSchedule(schedule); + const sharingInvalid = isOwner && !automationSharingIsValid(sharing, orgId); + const invalid = + nameInvalid || + bodyInvalid || + eventInvalid || + scheduleInvalid || + sharingInvalid; + + const reviewSummary = useMemo(() => { + switch (trigger) { + case "schedule": + return t("jobs.editorReviewSchedule", { + defaultValue: "Runs on {{schedule}} in {{timezone}}.", + schedule, + timezone, + }); + case "manual": + return t("jobs.editorReviewManual", { + defaultValue: "Runs only when someone starts it on demand.", + }); + case "email": { + const filters = [ + emailFilters.from.trim() + ? t("jobs.editorReviewEmailFromFilter", { + defaultValue: "from contains “{{value}}”", + value: emailFilters.from.trim(), + }) + : null, + emailFilters.to.trim() + ? t("jobs.editorReviewEmailToFilter", { + defaultValue: "to contains “{{value}}”", + value: emailFilters.to.trim(), + }) + : null, + emailFilters.subject.trim() + ? t("jobs.editorReviewEmailSubjectFilter", { + defaultValue: "subject contains “{{value}}”", + value: emailFilters.subject.trim(), + }) + : null, + emailFilters.additional.trim() || null, + ].filter((value): value is string => value !== null); + return filters.length + ? t("jobs.editorReviewEmailFiltered", { + defaultValue: "Runs when an email is received and {{condition}}.", + condition: filters.join(", "), + }) + : t("jobs.editorReviewEmail", { + defaultValue: "Runs whenever an email is received.", + }); + } + default: + return eventName + ? eventCondition.trim() + ? t("jobs.editorReviewConditionalEvent", { + defaultValue: "Runs when {{event}} occurs and {{condition}}.", + event: eventName, + condition: eventCondition.trim(), + }) + : t("jobs.editorReviewEvent", { + defaultValue: "Runs when {{event}} occurs.", + event: eventName, + }) + : t("jobs.editorReviewEventPending", { + defaultValue: "Choose the app event that starts this automation.", + }); + } + }, [emailFilters, eventCondition, eventName, schedule, t, timezone, trigger]); + + function submit() { + setSubmitted(true); + if (invalid) return; + + const needsAcknowledgement = automationSharingRequiresAcknowledgement( + sharing.grants, + ); + const sharingFields = isOwner + ? { + sharing: buildSharingSubmission(sharing, orgId), + ...(needsAcknowledgement + ? { + acknowledgeExternalCollaborators: + sharing.acknowledgeExternalCollaborators, + } + : {}), + } + : {}; + + const input: ManageAutomationInput = automation + ? { + operation: "update", + resourceId: automation.resourceId, + triggerType: trigger === "email" ? "event" : trigger, + body: body.trim(), + ...(trigger === "schedule" + ? { schedule, timezone, condition: null } + : {}), + ...(trigger === "event" + ? { event: eventName, condition: eventCondition.trim() || null } + : {}), + ...(trigger === "email" + ? { event: EMAIL_EVENT, condition: emailCondition(emailFilters) } + : {}), + ...sharingFields, + } + : { + operation: "create", + name: name.trim(), + scope, + triggerType: trigger === "email" ? "event" : trigger, + body: body.trim(), + ...(trigger === "schedule" + ? { schedule, timezone, condition: null } + : {}), + ...(trigger === "event" + ? { event: eventName, condition: eventCondition.trim() || null } + : {}), + ...(trigger === "email" + ? { event: EMAIL_EVENT, condition: emailCondition(emailFilters) } + : {}), + ...sharingFields, + }; + onSave(input); + } + + return ( +

{ + if (!next && !saving) onCancel(); + }} + > + + + + {automation + ? t("jobs.editorEditTitle", { defaultValue: "Edit automation" }) + : t("jobs.editorCreateTitle", { + defaultValue: "Create an automation", + })} + + + {t("jobs.editorScopeDescription", { + defaultValue: "This automation is fixed to the {{scope}} scope.", + scope: + scope === "organization" + ? t("jobs.editorScopeOrganization", { + defaultValue: "organization", + }) + : t("jobs.editorScopePersonal", { defaultValue: "personal" }), + })} + + + +
+
+ + setName(event.currentTarget.value)} + /> + {automation ? ( +

+ {t("jobs.editorNameImmutable", { + defaultValue: "The name cannot be changed after creation.", + })} +

+ ) : submitted && nameInvalid ? ( +

+ {t("jobs.editorNameRequired", { + defaultValue: "Enter a name.", + })} +

+ ) : null} +
+ + openAgentSettings("connections")} + /> + + {trigger === "schedule" ? ( + + ) : null} + + {trigger === "event" ? ( + + ) : null} + + {trigger === "email" ? ( + + ) : null} + +
+ +