diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index 2680752b6..b35b153ed 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -684,6 +684,39 @@ describe('custom-automations MCP routes', () => { ); }); + it('rebinds an ownerless DM target when a partial update claims ownership', async () => { + const { app } = createApp(); + mockGetCustomAutomationById.mockResolvedValue({ + ...existing, + createdByUserId: null, + target: { + provider: 'telegram', + targetKind: 'telegram_user', + externalRef: 'deleted-owner', + }, + }); + mockUpdateCustomAutomation.mockResolvedValue({ id: 'automation-1' }); + + const res = await app.request('/custom-automations/automation-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: false }), + }); + + expect(res.status).toBe(200); + expect(mockUpdateCustomAutomation).toHaveBeenCalledWith( + 'automation-1', + expect.objectContaining({ + createdByUserId: 'admin-1', + target: { + provider: 'telegram', + targetKind: 'telegram_user', + externalRef: 'admin-1', + }, + }), + ); + }); + it('rejects switching a DM target to channel mode without a channel', async () => { const { app } = createApp(); mockGetCustomAutomationById.mockResolvedValue({ diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index ea27c413e..b0e0cc4fe 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -491,6 +491,7 @@ customAutomationsRouter.patch('/:id', async (c) => { existing.createdByUserId ?? adminId(c), ) : existingTarget, + createdByUserId: existing.createdByUserId ?? adminId(c), }); return c.json({ automation: toApiAutomation(automation), diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 233661c11..44bcef422 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -85,6 +85,21 @@ conflict resolution, and remove it when a human should handle the conflict instead. Roomote only tries this on labeled PRs that are still active, and it skips PRs older than the age cap you set. +Built-in repository scans, audits, issue and CI investigations, PR reviews, +conflict resolution, setup scans, and snapshot maintenance continue to start +tasks directly. Those automations either have no human run-as identity, produce +structured results consumed by another Roomote workflow, or require a sandbox +by definition. Deterministic alert and stats automations that do not need an +agent post their result directly without launching a task. + +Roomote is rolling out automation-owned Fast sessions in two releases. This +release adds the principal-aware storage, read, and authorization shape, but +continues writing only human-owned Fast sessions. Built-in and integration +automation principals therefore keep their existing direct-task behavior until +the follow-up release enables deployment-principal capabilities and Session +writes. This keeps one-release rollback safe: the previous version never sees a +new ownerless Session row. + ## Custom automations Create arbitrary scheduled agent runs with: @@ -92,7 +107,8 @@ Create arbitrary scheduled agent runs with: - a clear **name** - the **prompt** Roomote should run - a **cadence** (`every hour`, `every 6 hours`, `daily`, or `weekly`) -- one required **execution target**: **Fast**, a named environment, or **All repositories** +- one required **delegated task scope**: no default environment, a named + environment, or **All repositories** - an optional **model** override for the runs; the default follows the deployment task model - an optional **report destination**: a direct message to the automation owner, @@ -101,9 +117,9 @@ Create arbitrary scheduled agent runs with: Each automation card summarizes its cadence, workspace target, report destination, creator, and most recent run. -Use **View previous runs** on an automation card to open the task list -filtered to that automation's runs. For a custom automation, the history is -scoped to that specific automation rather than all custom automations. +Legacy ownerless automations that still run directly in a sandbox show **View +previous runs** on their automation card. Owned automation results stay with +their Fast sessions and any tasks delegated from those sessions. When creating an automation through Roomote chat, ask for **suggested tasks** or **launchable follow-ups** if qualifying findings should become tasks that @@ -121,10 +137,13 @@ five-field cron expression or a natural-language schedule such as “weekdays at clarification rather than guessing when the recurrence itself is ambiguous. Custom schedules do not support seconds or cron macros. -On each due tick, Roomote either launches a normal task in the selected -environment (or across all active repositories), or runs the prompt directly in -**Fast** without starting a sandbox. A Fast run can still delegate a normal task -when repository or workspace execution is required. +On each due tick, an owned custom automation starts in **Fast** without a +sandbox. Fast uses integrations directly when that is enough and delegates a +normal task only when repository or workspace execution is required. The +selected task scope controls that delegated task; it does not force every run to +start a sandbox. Legacy automation records whose owner was deleted retain their +previous direct sandbox behavior because there is no user identity that can +authorize a Fast session safely. Fast runs deliver to every custom-automation report destination: Slack, Discord, Microsoft Teams, or Telegram, as either a channel/chat or a direct @@ -144,9 +163,9 @@ or important findings, meaningful completed results, blockers, and questions that need input. Routine success, healthy status, and no-change results stay silent unless the automation prompt explicitly asks for a report in those cases. The first message starts a thread, later updates continue that same -thread, and you can reply there to talk to the task. There is no progress -chatter in between. Without a destination, the run happens silently and its -results appear only in the task view. +thread, and you can reply there to continue the Fast session. There is no +progress chatter in between. Without a destination, the run happens silently +and its result remains in the stored Fast session. Unless the prompt asks for a different presentation, custom automation reports lead with the result, stay concise, and use short Markdown headings and bullets diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 5d8930f6d..8853fec5a 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -373,6 +373,26 @@ describe('FastSessionTranscript', () => { }); }); + it('shows staged automation sessions as read-only without a composer', () => { + render( + , + ); + + expect( + screen.getByText( + 'Automation-owned sessions are read-only until the next release.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('Message agent'), + ).not.toBeInTheDocument(); + }); + it('sends an image-only reply', async () => { preparePromptAttachments.mockResolvedValueOnce({ text: '', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..ce82381d2 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -55,6 +55,7 @@ export function FastSessionTranscript({ initialMessages, hasOlderMessages, canReply, + readOnlyReason, initialTitle = null, fallbackTitle = 'Session', sessionModel = null, @@ -66,6 +67,7 @@ export function FastSessionTranscript({ initialMessages: FastSessionMessage[]; hasOlderMessages?: boolean; canReply?: boolean; + readOnlyReason?: string; initialTitle?: string | null; fallbackTitle?: string; sessionModel?: string | null; @@ -289,6 +291,10 @@ export function FastSessionTranscript({

{replyError}

) : null} + ) : readOnlyReason ? ( +

+ {readOnlyReason} +

) : null} ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..e720b47d4 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -68,7 +68,12 @@ export default async function SessionDetailPage({ sessionId={session.id} initialMessages={session.messages} hasOlderMessages={session.hasOlderMessages} - canReply + canReply={!session.ownerAutomation} + readOnlyReason={ + session.ownerAutomation + ? 'Automation-owned sessions are read-only until the next release.' + : undefined + } initialTitle={session.title} fallbackTitle={fallbackTitle} sessionModel={session.model} diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 5965fa397..23f037031 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -22,6 +22,7 @@ const state = vi.hoisted(() => ({ cronExpression: string | null; model: null; executionMode?: 'sandbox_task' | 'fast'; + launchMode?: 'fast_session' | 'legacy_sandbox_task' | 'unavailable'; environmentId: string; target: { provider?: 'slack' | 'discord' | 'teams' | 'telegram'; @@ -280,7 +281,11 @@ const mutations = vi.hoisted(() => ({ ) => void; } | null, latestCustomTriggerOptions: null as { - onSuccess?: (result: { outcome: 'launched'; taskId: string }) => void; + onSuccess?: ( + result: + | { outcome: 'launched'; taskId: string } + | { outcome: 'completed' }, + ) => void; } | null, })); @@ -1082,6 +1087,7 @@ describe('AutomationsSettings', () => { scheduleMode: 'weekly', cronExpression: null, model: null, + launchMode: 'fast_session', environmentId: 'env-1', target: { provider: 'slack', externalRef: 'C123MANAGER' }, lastRunAt: null, @@ -1108,13 +1114,10 @@ describe('AutomationsSettings', () => { screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ).toBeEnabled(); expect( - screen.getByRole('link', { + screen.queryByRole('link', { name: 'View previous runs for Weekly flaky-test scan', }), - ).toHaveAttribute( - 'href', - '/tasks?userId=automation%3Acustom_automation%3Aautomation-1', - ); + ).not.toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ); @@ -1123,15 +1126,11 @@ describe('AutomationsSettings', () => { }); act(() => { mutations.latestCustomTriggerOptions?.onSuccess?.({ - outcome: 'launched', - taskId: 'task-custom-1', + outcome: 'completed', }); }); expect(toast.success).toHaveBeenCalledWith( - 'Running Weekly flaky-test scan now', - expect.objectContaining({ - action: expect.objectContaining({ label: 'View task' }), - }), + 'Weekly flaky-test scan ran successfully.', ); state.customAutomations.push({ @@ -1161,10 +1160,10 @@ describe('AutomationsSettings', () => { }), ).toBeInTheDocument(); expect( - screen.getByRole('link', { + screen.queryByRole('link', { name: 'View previous runs for Weekly flaky-test scan', }), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Delete Weekly flaky-test scan' }), ).toBeInTheDocument(); @@ -1199,6 +1198,7 @@ describe('AutomationsSettings', () => { scheduleMode: 'daily', cronExpression: null, model: null, + launchMode: 'fast_session', environmentId: '__all_repositories__', target: { provider: 'slack', externalRef: 'C123MANAGER' }, lastRunAt: null, @@ -1218,7 +1218,9 @@ describe('AutomationsSettings', () => { await screen.findByText('Daily, in All repositories →'), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'New' })); - fireEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + fireEvent.click( + screen.getByRole('combobox', { name: 'Delegated task environment' }), + ); expect( screen.getByRole('option', { name: 'All repositories' }), ).toBeInTheDocument(); @@ -1235,6 +1237,7 @@ describe('AutomationsSettings', () => { cronExpression: null, model: null, executionMode: 'fast', + launchMode: 'fast_session', environmentId: '__fast__', target: {}, lastRunAt: null, @@ -1251,7 +1254,11 @@ describe('AutomationsSettings', () => { render(); - expect(await screen.findByText('Daily, in Fast →')).toBeInTheDocument(); + expect( + await screen.findByText( + 'Daily, in Fast with no default task environment →', + ), + ).toBeInTheDocument(); expect( screen.getByText('No actionable regressions found.'), ).toBeInTheDocument(); @@ -1266,15 +1273,59 @@ describe('AutomationsSettings', () => { expect(screen.getByText('Delegated task model')).toBeInTheDocument(); expect( screen.getByText( - 'This run is stored as a Fast conversation without posting to chat.', + 'Each run starts as a stored Fast session. A sandbox task uses the selected environment only when workspace execution is required.', ), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + fireEvent.click( + screen.getByRole('combobox', { name: 'Delegated task environment' }), + ); expect( - screen.getByRole('option', { name: 'Fast (no sandbox)' }), + screen.getByRole('option', { name: 'No default task environment' }), ).toBeInTheDocument(); }); + it('keeps an owner-deleted Fast automation unavailable until it is claimed', async () => { + state.customAutomations = [ + { + id: 'automation-unavailable', + name: 'Ownerless Fast digest', + prompt: 'Summarize priorities.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + executionMode: 'fast', + launchMode: 'unavailable', + environmentId: '__fast__', + target: {}, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByName: 'Unknown', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + + render(); + + expect( + await screen.findByText( + 'Owner unavailable. Configure and save to claim this automation before running it.', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Run Ownerless Fast digest now' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { + name: 'Configure Ownerless Fast digest', + }), + ).toBeEnabled(); + }); + it('humanizes custom schedules and shows the last run when available', async () => { state.environments = [{ id: 'env-1', name: 'Production' }]; state.customAutomations = [ @@ -1357,7 +1408,7 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); @@ -1408,7 +1459,7 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); @@ -1451,7 +1502,7 @@ describe('AutomationsSettings', () => { expect( screen.getByText( - 'Each Fast run posts here, and replies continue the Fast session.', + 'Each run starts as Fast and posts here; replies continue the Fast session.', ), ).toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index ade8f27e6..6708b5bd5 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -326,7 +326,7 @@ export function CustomAutomationsSection() { const environmentOptions = useMemo( () => [ - { id: FAST_EXECUTION, name: 'Fast (no sandbox)' }, + { id: FAST_EXECUTION, name: 'No default task environment' }, { id: ALL_REPOSITORIES, name: 'All repositories' }, ...(environmentsQuery.data ?? []).map((environment) => ({ id: environment.id, @@ -727,7 +727,9 @@ export function CustomAutomationsSection() {
- +