- {Array.from({ length: 2 }).map((_, index) => (
-
-
-
-
-
-
+
+
+
+
+
+ {listQuery.isPending && filter !== 'built-in' ? (
+
+ {Array.from({ length: 2 }).map((_, index) => (
+
+ ))}
- ))}
-
-
-
- ) : rows.length === 0 && !isCreating ? (
-
- No custom automations created yet.
-
- ) : (
-
-
-
- {rows.map((row) => {
+ ) : null}
+ {!listQuery.isPending &&
+ visibleRows.length === 0 &&
+ (filter === 'custom' || (!children && filter === 'all')) ? (
+
+ {normalizedSearch
+ ? 'No custom automations match your search.'
+ : 'No custom automations created yet.'}
+
+ ) : null}
+ {visibleRows.map((row) => {
const environmentName =
row.executionMode === 'fast'
? null
@@ -998,34 +1070,29 @@ export function CustomAutomationsSection() {
(option) => option.id === target.channelId,
)?.label ?? target.channelId)
: target.channelId;
- const historyFilter = buildCreatorFilterValue({
- initiatorKind: 'automation',
- initiatorUserId: null,
- initiatorAutomation: 'custom_automation',
- actorExternalId: row.id,
- });
-
return (
-
-
- toggleMutation.mutate({
- id: row.id,
- ...writeInputFromRow(row),
- enabled,
- })
- }
- />
-
-
{row.name}
-
+ icon={Zap}
+ name={row.name}
+ description={
{row.prompt}
}
+ enabledControl={
+
+ toggleMutation.mutate({
+ id: row.id,
+ ...writeInputFromRow(row),
+ enabled,
+ })
+ }
+ />
+ }
+ summary={
+ <>
{cadenceLabel(row)}
{environmentName
@@ -1043,86 +1110,81 @@ export function CustomAutomationsSection() {
{destinationName}
{destinationLabel ? ` ${destinationLabel}` : ''}
-
-
- Created by {row.createdByName ?? 'Unknown'}
- {row.lastRunAt ? (
- <>
- {' · Last run '}
-
- {formatDistanceToNowCompact(
- new Date(row.lastRunAt),
- { addSuffix: true },
- )}
-
- >
- ) : null}
-
- {row.latestFastResult ? (
-
- {row.latestFastResult}
-
- ) : null}
-
-
- {historyFilter ? (
-
-
-
+
+ {
+ if (
+ window.confirm(
+ `Delete custom automation “${row.name}”?`,
+ )
+ ) {
+ deleteMutation.mutate({ id: row.id });
+ }
+ }}
+ aria-label={`Delete ${row.name}`}
+ >
+
+
+
+ >
+ }
+ />
);
})}
+ {filter !== 'custom' ? children : null}
+ {!listQuery.isPending &&
+ visibleRows.length === 0 &&
+ !children &&
+ filter !== 'custom' &&
+ filter !== 'all' ? (
+
+ No automations match your filters.
+
+ ) : null}
-
-
- )}
+
+
+
);
}
diff --git a/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx b/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx
index 70fe6a3e2..843a06583 100644
--- a/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx
+++ b/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
const state = vi.hoisted(() => ({ isAdmin: false }));
@@ -7,7 +8,12 @@ vi.mock('@/hooks/useUser', () => ({
}));
vi.mock('@/components/settings/automations', () => ({
- AutomationsSettings: () =>
Full automation settings
,
+ AutomationsSettings: ({ toolbarLeading }: { toolbarLeading?: ReactNode }) => (
+ <>
+ {toolbarLeading}
+
Full automation settings
+ >
+ ),
}));
vi.mock('@/components/settings/automations/CustomAutomationsSection', () => ({
diff --git a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
index 9fa2c02e5..9eded11f7 100644
--- a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
+++ b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
@@ -33,10 +33,13 @@ export function AutomationsSettingsPage() {
{isAdmin ? (
- <>
-
-
- >
+
+
+
+ }
+ />
) : (
)}
diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts
index 960e7513e..f6a0c656b 100644
--- a/apps/web/src/components/system/primitives/icons.ts
+++ b/apps/web/src/components/system/primitives/icons.ts
@@ -159,7 +159,6 @@ export {
RefreshCwIcon,
RectangleHorizontal,
RotateCcw,
- History as RotateCcwClock,
RotateCcwKey,
ScrollText,
Rows4,
diff --git a/apps/web/src/testing/exclusive-automation-settings-database-lock.ts b/apps/web/src/testing/exclusive-automation-settings-database-lock.ts
new file mode 100644
index 000000000..1174a7442
--- /dev/null
+++ b/apps/web/src/testing/exclusive-automation-settings-database-lock.ts
@@ -0,0 +1,36 @@
+import { db, sql } from '@roomote/db/server';
+
+const AUTOMATION_SETTINGS_TEST_LOCK = [20260910, 2451] as const;
+
+/** Serializes suites that replace deployment-wide automation settings rows. */
+export function registerExclusiveAutomationSettingsDatabaseLock() {
+ let releaseLock: (() => void) | undefined;
+ let lockTransaction: Promise
| undefined;
+
+ beforeAll(async () => {
+ let markAcquired: (() => void) | undefined;
+ let rejectAcquired: ((error: unknown) => void) | undefined;
+ const acquired = new Promise((resolve, reject) => {
+ markAcquired = resolve;
+ rejectAcquired = reject;
+ });
+ const released = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+
+ lockTransaction = db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(${AUTOMATION_SETTINGS_TEST_LOCK[0]}, ${AUTOMATION_SETTINGS_TEST_LOCK[1]})`,
+ );
+ markAcquired?.();
+ await released;
+ });
+ void lockTransaction.catch((error) => rejectAcquired?.(error));
+ await acquired;
+ });
+
+ afterAll(async () => {
+ releaseLock?.();
+ await lockTransaction;
+ });
+}
diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts
index 90cf8764b..f76634f92 100644
--- a/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts
+++ b/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts
@@ -10,9 +10,12 @@ import {
import { USER_FACING_AUTOMATION_KEYS } from '@roomote/types';
import type { UserAuthSuccess } from '@/types';
+import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock';
import { getBackgroundAgentSettingsCommand } from '../settings-read';
+registerExclusiveAutomationSettingsDatabaseLock();
+
const SETTINGS_READ_USER_ID = 'user-settings-read-admin';
const MANAGER_CHANNEL_ID = 'CMANAGER1';
const MANAGER_DISCORD_CHANNEL_ID = 'DMANAGER1';
diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
index b6a7bc9c1..6e5c3f399 100644
--- a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
+++ b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
@@ -20,6 +20,7 @@ import {
} from '@roomote/types';
import type { UserAuthSuccess } from '@/types';
+import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock';
import { updateBackgroundAgentSettingsCommand } from '../settings-update';
import { mergeAnnouncerDestinationInputSchema } from '../settings-schema';
@@ -60,6 +61,8 @@ vi.mock('@roomote/telemetry/server', () => ({
captureActivationAutomationChanged: mockCaptureActivationAutomationChanged,
}));
+registerExclusiveAutomationSettingsDatabaseLock();
+
// Keep the test hermetic: the command constructs a SlackNotifier whenever a
// Slack installation exists and probes channel membership/names after saving.
vi.mock('@roomote/slack', async (importOriginal) => {
@@ -273,6 +276,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => {
});
it('tracks a built-in automation when its enabled state changes', async () => {
+ await upsertAutomation(db, {
+ key: 'manager_stats',
+ enabled: false,
+ schedule: { mode: 'weekly' },
+ });
await insertAvailableDiscordChannel({
guildId: 'guild-1',
channelId: 'channel-1',
@@ -611,6 +619,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => {
});
it('does not track a built-in automation when its enabled state is unchanged', async () => {
+ await upsertAutomation(db, {
+ key: 'manager_stats',
+ enabled: false,
+ schedule: { mode: 'weekly' },
+ });
const result = await updateBackgroundAgentSettingsCommand(
adminAuth,
buildInput({ savingAutomation: 'managerStats' }),
@@ -800,6 +813,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => {
}, 15_000);
it('switches a Discord manager channel to Slack and clears Discord', async () => {
+ await upsertAutomation(db, {
+ key: 'manager_stats',
+ enabled: false,
+ schedule: { mode: 'weekly' },
+ });
await insertSlackInstallation();
await db.insert(deploymentSettings).values({
id: 'default',
diff --git a/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts b/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts
index 04c751944..4a3bff473 100644
--- a/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts
+++ b/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts
@@ -13,6 +13,9 @@ import {
} from '@roomote/db/server';
import { USER_FACING_AUTOMATION_KEYS } from '@roomote/types';
import type { UserAuthSuccess } from '@/types';
+import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock';
+
+registerExclusiveAutomationSettingsDatabaseLock();
const { ensureChannel, education, decodeState, fetchMock } = vi.hoisted(() => ({
ensureChannel: vi.fn(),