From dce925b3692eced13396613a7031069bb9fbf894 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 30 Jul 2025 21:30:25 +0200
Subject: [PATCH 01/83] Script list simple inlined

---
 ui.frontend/package-lock.json                 | 28 -------
 .../src/components/ScriptExtensionList.tsx    | 79 +++++++++++++++++++
 ...criptListSimple.tsx => ScriptMockList.tsx} | 44 ++++-------
 ui.frontend/src/pages/ScriptsPage.tsx         |  7 +-
 4 files changed, 100 insertions(+), 58 deletions(-)
 create mode 100644 ui.frontend/src/components/ScriptExtensionList.tsx
 rename ui.frontend/src/components/{ScriptListSimple.tsx => ScriptMockList.tsx} (61%)

diff --git a/ui.frontend/package-lock.json b/ui.frontend/package-lock.json
index 8b5d2524..01f1ea86 100644
--- a/ui.frontend/package-lock.json
+++ b/ui.frontend/package-lock.json
@@ -11,7 +11,6 @@
       "dependencies": {
         "@adobe/react-spectrum": "^3.40.1",
         "@monaco-editor/react": "^4.6.0",
-        "@preact/signals-react": "^3.2.0",
         "@react-spectrum/toast": "^3.0.1",
         "@spectrum-icons/illustrations": "^3.6.20",
         "@spectrum-icons/workflow": "^4.2.19",
@@ -1247,33 +1246,6 @@
         "url": "https://opencollective.com/unts"
       }
     },
-    "node_modules/@preact/signals-core": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.10.0.tgz",
-      "integrity": "sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA==",
-      "license": "MIT",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/preact"
-      }
-    },
-    "node_modules/@preact/signals-react": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-3.2.0.tgz",
-      "integrity": "sha512-7EXQnA5+FMOW7TrmrQk/LSKkuoPgwKHXBtkTZ3YR+j34XDwBu7QHCmSHqsTp83A2DStbBg2NshA155QUbRoziw==",
-      "license": "MIT",
-      "dependencies": {
-        "@preact/signals-core": "^1.9.0",
-        "use-sync-external-store": "^1.2.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/preact"
-      },
-      "peerDependencies": {
-        "react": "^16.14.0 || 17.x || 18.x || 19.x"
-      }
-    },
     "node_modules/@react-aria/actiongroup": {
       "version": "3.7.14",
       "resolved": "https://registry.npmjs.org/@react-aria/actiongroup/-/actiongroup-3.7.14.tgz",
diff --git a/ui.frontend/src/components/ScriptExtensionList.tsx b/ui.frontend/src/components/ScriptExtensionList.tsx
new file mode 100644
index 00000000..cf340fe7
--- /dev/null
+++ b/ui.frontend/src/components/ScriptExtensionList.tsx
@@ -0,0 +1,79 @@
+import { Cell, Column, Content, Flex, IllustratedMessage, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
+import NotFound from '@spectrum-icons/illustrations/NotFound';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { toastRequest } from '../utils/api';
+import { ScriptOutput, ScriptType } from '../utils/api.types';
+import ScriptsExtensionHelpButton from './ScriptsExtensionHelpButton.tsx';
+
+const ScriptExtensionList: React.FC = () => {
+  const type = ScriptType.EXTENSION;
+  const [scripts, setScripts] = useState<ScriptOutput | null>(null);
+  const scriptCount = scripts?.list?.length ?? 0;
+  const [loading, setLoading] = useState<boolean>(true);
+  const navigate = useNavigate();
+
+  const loadScripts = useCallback(() => {
+    setLoading(true);
+    toastRequest<ScriptOutput>({
+      method: 'GET',
+      url: `/apps/acm/api/script.json?type=${type}`,
+      operation: `Script extensions loading (${type.toString().toLowerCase()})`,
+      positive: false,
+    })
+      .then((data) => setScripts(data.data.data))
+      .catch((error) => console.error(`Script extensions loading (${type}) error:`, error))
+      .finally(() => setLoading(false));
+  }, [type]);
+
+  useEffect(() => {
+    loadScripts();
+  }, [type, loadScripts]);
+
+  const renderEmptyState = () => (
+    <IllustratedMessage>
+      <NotFound />
+      <Content>No extensions found</Content>
+    </IllustratedMessage>
+  );
+
+  if (scripts === null || loading) {
+    return (
+      <Flex flex="1" justifyContent="center" alignItems="center">
+        <ProgressBar label="Loading..." isIndeterminate />
+      </Flex>
+    );
+  }
+
+  return (
+    <Flex direction="column" flex="1" gap="size-200" marginY="size-100">
+      <View>
+        <Flex direction="row" justifyContent="space-between" alignItems="center">
+          <Flex flex="1" alignItems="center">
+            &nbsp;
+          </Flex>
+          <Flex flex="1" justifyContent="center" alignItems="center">
+            <StatusLight variant={scriptCount > 0 ? 'positive' : 'negative'}>{scriptCount > 0 ? <Text>Installed ({scriptCount})</Text> : <Text>Not installed</Text>}</StatusLight>
+          </Flex>
+          <Flex flex="1" justifyContent="end" alignItems="center">
+            <ScriptsExtensionHelpButton />
+          </Flex>
+        </Flex>
+      </View>
+      <TableView flex="1" aria-label={`Script list (${type})`} renderEmptyState={renderEmptyState} onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}>
+        <TableHeader>
+          <Column>Name</Column>
+        </TableHeader>
+        <TableBody>
+          {(scripts.list || []).map((script) => (
+            <Row key={script.id}>
+              <Cell>{script.name}</Cell>
+            </Row>
+          ))}
+        </TableBody>
+      </TableView>
+    </Flex>
+  );
+};
+
+export default ScriptExtensionList;
diff --git a/ui.frontend/src/components/ScriptListSimple.tsx b/ui.frontend/src/components/ScriptMockList.tsx
similarity index 61%
rename from ui.frontend/src/components/ScriptListSimple.tsx
rename to ui.frontend/src/components/ScriptMockList.tsx
index 8b9c84e8..f0f3dd80 100644
--- a/ui.frontend/src/components/ScriptListSimple.tsx
+++ b/ui.frontend/src/components/ScriptMockList.tsx
@@ -6,14 +6,10 @@ import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app.ts';
 import { toastRequest } from '../utils/api';
 import { InstanceType, ScriptOutput, ScriptType, instancePrefix } from '../utils/api.types';
-import ScriptsExtensionHelpButton from './ScriptsExtensionHelpButton.tsx';
 import ScriptsMockHelpButton from './ScriptsMockHelpButton.tsx';
 
-type ScriptListSimpleProps = {
-  type: ScriptType;
-};
-
-const ScriptListSimple: React.FC<ScriptListSimpleProps> = ({ type }) => {
+const ScriptMockList: React.FC = () => {
+  const type = ScriptType.MOCK;
   const appState = useAppState();
   const [scripts, setScripts] = useState<ScriptOutput | null>(null);
   const scriptCount = scripts?.list?.length ?? 0;
@@ -25,11 +21,11 @@ const ScriptListSimple: React.FC<ScriptListSimpleProps> = ({ type }) => {
     toastRequest<ScriptOutput>({
       method: 'GET',
       url: `/apps/acm/api/script.json?type=${type}`,
-      operation: `Scripts loading (${type.toString().toLowerCase()})`,
+      operation: `Script mocks loading`,
       positive: false,
     })
       .then((data) => setScripts(data.data.data))
-      .catch((error) => console.error(`Scripts loading (${type}) error:`, error))
+      .catch((error) => console.error(`Script mocks loading (${type}) error:`, error))
       .finally(() => setLoading(false));
   }, [type]);
 
@@ -58,29 +54,23 @@ const ScriptListSimple: React.FC<ScriptListSimpleProps> = ({ type }) => {
         <Flex direction="row" justifyContent="space-between" alignItems="center">
           <Flex flex="1" alignItems="center">
             <ButtonGroup>
-              {type === ScriptType.MOCK ? (
-                <Button
-                  variant="negative"
-                  isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
-                  onPress={() => window.open(`${instancePrefix}/system/console/configMgr/dev.vml.es.acm.core.mock.MockHttpFilter`, '_blank')}
-                >
-                  <Settings />
-                  <Text>Configure</Text>
-                </Button>
-              ) : null}
+              <Button
+                variant="negative"
+                isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
+                onPress={() => window.open(`${instancePrefix}/system/console/configMgr/dev.vml.es.acm.core.mock.MockHttpFilter`, '_blank')}
+              >
+                <Settings />
+                <Text>Configure</Text>
+              </Button>
             </ButtonGroup>
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
-            {type === ScriptType.MOCK ? (
-              <StatusLight variant={appState.mockStatus.enabled ? (scriptCount > 0 ? 'positive' : 'neutral') : 'negative'}>
-                {appState.mockStatus.enabled ? scriptCount > 0 ? <Text>Installed ({scriptCount})</Text> : <Text>Not installed</Text> : <Text>Disabled</Text>}
-              </StatusLight>
-            ) : null}
-            {type === ScriptType.EXTENSION ? <StatusLight variant={scriptCount > 0 ? 'positive' : 'negative'}>{scriptCount > 0 ? <Text>Installed ({scriptCount})</Text> : <Text>Not installed</Text>}</StatusLight> : null}
+            <StatusLight variant={appState.mockStatus.enabled ? (scriptCount > 0 ? 'positive' : 'neutral') : 'negative'}>
+              {appState.mockStatus.enabled ? scriptCount > 0 ? <Text>Installed ({scriptCount})</Text> : <Text>Not installed</Text> : <Text>Disabled</Text>}
+            </StatusLight>
           </Flex>
           <Flex flex="1" justifyContent="end" alignItems="center">
-            {type === ScriptType.MOCK ? <ScriptsMockHelpButton /> : null}
-            {type === ScriptType.EXTENSION ? <ScriptsExtensionHelpButton /> : null}
+            <ScriptsMockHelpButton />
           </Flex>
         </Flex>
       </View>
@@ -100,4 +90,4 @@ const ScriptListSimple: React.FC<ScriptListSimpleProps> = ({ type }) => {
   );
 };
 
-export default ScriptListSimple;
+export default ScriptMockList;
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index 433f4a65..895ea6ec 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -5,11 +5,12 @@ import Extension from '@spectrum-icons/workflow/Extension';
 import FlashOn from '@spectrum-icons/workflow/FlashOn';
 import Hand from '@spectrum-icons/workflow/Hand';
 import ScriptListRich from '../components/ScriptListRich.tsx';
-import ScriptListSimple from '../components/ScriptListSimple.tsx';
 import { useAppState } from '../hooks/app.ts';
 import { useNavigationTab } from '../hooks/navigation';
 import { ScriptType } from '../utils/api.types';
 import styles from './ScriptsPage.module.css';
+import ScriptMockList from "../components/ScriptMockList.tsx";
+import ScriptExtensionList from "../components/ScriptExtensionList.tsx";
 
 const ScriptsPage = () => {
   const appState = useAppState();
@@ -54,11 +55,11 @@ const ScriptsPage = () => {
           </Item>
           {appState.mockStatus.enabled ? (
             <Item key="mock" aria-label="Mock">
-              <ScriptListSimple type={ScriptType.MOCK} />
+              <ScriptMockList/>
             </Item>
           ) : null}
           <Item key="extension" aria-label="Extension">
-            <ScriptListSimple type={ScriptType.EXTENSION} />
+            <ScriptExtensionList/>
           </Item>
         </TabPanels>
       </Tabs>

From 2e50befeda827de642ac813ec74aae772ffba02f Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 30 Jul 2025 21:48:05 +0200
Subject: [PATCH 02/83] Script manual list inlined

---
 .../src/components/ScriptExtensionList.tsx    |  26 +---
 .../src/components/ScriptManualList.tsx       | 130 ++++++++++++++++++
 ui.frontend/src/components/ScriptMockList.tsx |  30 +---
 ui.frontend/src/hooks/script.ts               |  27 ++++
 ui.frontend/src/pages/ScriptsPage.tsx         |   9 +-
 5 files changed, 172 insertions(+), 50 deletions(-)
 create mode 100644 ui.frontend/src/components/ScriptManualList.tsx
 create mode 100644 ui.frontend/src/hooks/script.ts

diff --git a/ui.frontend/src/components/ScriptExtensionList.tsx b/ui.frontend/src/components/ScriptExtensionList.tsx
index cf340fe7..26637cbc 100644
--- a/ui.frontend/src/components/ScriptExtensionList.tsx
+++ b/ui.frontend/src/components/ScriptExtensionList.tsx
@@ -1,35 +1,17 @@
 import { Cell, Column, Content, Flex, IllustratedMessage, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
-import React, { useCallback, useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { toastRequest } from '../utils/api';
-import { ScriptOutput, ScriptType } from '../utils/api.types';
+import { ScriptType } from '../utils/api.types';
 import ScriptsExtensionHelpButton from './ScriptsExtensionHelpButton.tsx';
+import { useScripts } from '../hooks/script';
+import React from "react";
 
 const ScriptExtensionList: React.FC = () => {
   const type = ScriptType.EXTENSION;
-  const [scripts, setScripts] = useState<ScriptOutput | null>(null);
+  const {scripts, loading } = useScripts(type);
   const scriptCount = scripts?.list?.length ?? 0;
-  const [loading, setLoading] = useState<boolean>(true);
   const navigate = useNavigate();
 
-  const loadScripts = useCallback(() => {
-    setLoading(true);
-    toastRequest<ScriptOutput>({
-      method: 'GET',
-      url: `/apps/acm/api/script.json?type=${type}`,
-      operation: `Script extensions loading (${type.toString().toLowerCase()})`,
-      positive: false,
-    })
-      .then((data) => setScripts(data.data.data))
-      .catch((error) => console.error(`Script extensions loading (${type}) error:`, error))
-      .finally(() => setLoading(false));
-  }, [type]);
-
-  useEffect(() => {
-    loadScripts();
-  }, [type, loadScripts]);
-
   const renderEmptyState = () => (
     <IllustratedMessage>
       <NotFound />
diff --git a/ui.frontend/src/components/ScriptManualList.tsx b/ui.frontend/src/components/ScriptManualList.tsx
new file mode 100644
index 00000000..d3b76de4
--- /dev/null
+++ b/ui.frontend/src/components/ScriptManualList.tsx
@@ -0,0 +1,130 @@
+import { Button, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, Link, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
+import NotFound from '@spectrum-icons/illustrations/NotFound';
+import Magnify from '@spectrum-icons/workflow/Magnify';
+import { useNavigate } from 'react-router-dom';
+import { useAppState } from '../hooks/app';
+import { useFormatter } from '../hooks/formatter';
+import { isExecutionNegative, ScriptType } from '../utils/api.types';
+import DateExplained from './DateExplained';
+import ExecutionStatsBadge from './ExecutionStatsBadge';
+import ScriptsManualHelpButton from './ScriptsManualHelpButton';
+import UserInfo from './UserInfo';
+import React from "react";
+import {useScripts} from "../hooks/script";
+
+const ScriptManualList: React.FC = () => {
+  const type = ScriptType.MANUAL;
+  const {scripts, loading} = useScripts(type);
+  const appState = useAppState();
+  const navigate = useNavigate();
+  const formatter = useFormatter();
+
+  const renderEmptyState = () => (
+    <IllustratedMessage>
+      <NotFound />
+      <Content>No scripts found</Content>
+    </IllustratedMessage>
+  );
+
+  if (scripts === null || loading) {
+    return (
+      <Flex flex="1" justifyContent="center" alignItems="center">
+        <ProgressBar label="Loading..." isIndeterminate />
+      </Flex>
+    );
+  }
+
+  return (
+    <Flex direction="column" flex="1" gap="size-200" marginY="size-100">
+      <View>
+        <Flex direction="row" justifyContent="space-between" alignItems="center">
+          <Flex flex="1" alignItems="center">
+            &nbsp;
+          </Flex>
+          <Flex flex="1" justifyContent="center" alignItems="center">
+            <StatusLight variant={appState.healthStatus.healthy ? 'positive' : 'negative'}>
+              {appState.healthStatus.healthy ? (
+                <Text>Executor active</Text>
+              ) : (
+                <>
+                  <Text>Executor paused</Text>
+                  <Text>&nbsp;&mdash;&nbsp;</Text>
+                  <Link isQuiet onPress={() => navigate('/maintenance?tab=health-checker')}>
+                    See health issues
+                  </Link>
+                </>
+              )}
+            </StatusLight>
+          </Flex>
+          <Flex flex="1" justifyContent="end" alignItems="center">
+            <ScriptsManualHelpButton />
+          </Flex>
+        </Flex>
+      </View>
+      <TableView
+        flex="1"
+        aria-label={`Script list (${type})`}
+        selectionMode={'none'}
+        renderEmptyState={renderEmptyState}
+        onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
+      >
+        <TableHeader>
+          <Column width="4fr">Name</Column>
+          <Column width="5fr">Last Execution</Column>
+          <Column width="3fr">
+            <Text>Average Duration</Text>
+            <ContextualHelp variant="info">
+              <Heading>Explanation</Heading>
+              <Content>Duration is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
+            </ContextualHelp>
+          </Column>
+          <Column width="2fr" align="end">
+            <Text>Success Rate</Text>
+            <ContextualHelp variant="info">
+              <Heading>Explanation</Heading>
+              <Content>Success rate is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
+            </ContextualHelp>
+          </Column>
+        </TableHeader>
+        <TableBody>
+          {(scripts.list || []).map((script) => {
+            const scriptStats = scripts.stats.find((stat) => stat.path === script.id)!;
+            const lastExecution = scriptStats?.lastExecution;
+
+            return (
+              <Row key={script.id}>
+                <Cell>{script.name}</Cell>
+                <Cell>
+                  <Flex alignItems="center" gap="size-100">
+                    {lastExecution ? (
+                      <>
+                        <Button variant={isExecutionNegative(lastExecution.status) ? 'negative' : 'secondary'} onPress={() => navigate(`/executions/view/${encodeURIComponent(lastExecution.id)}`)} aria-label="View Execution">
+                          <Magnify />
+                        </Button>
+                        <Text>
+                          <DateExplained value={lastExecution.startDate} />
+                        </Text>
+                        <Text>by</Text>
+                        <UserInfo id={lastExecution.userId} />
+                      </>
+                    ) : (
+                      <Text>&mdash;</Text>
+                    )}
+                  </Flex>
+                </Cell>
+                <Cell>
+                  <Text>{lastExecution ? formatter.duration(scriptStats.averageDuration) : <>&mdash;</>}</Text>
+                </Cell>
+                <Cell>
+                  <ExecutionStatsBadge stats={scriptStats} />
+                </Cell>
+              </Row>
+            );
+          })}
+        </TableBody>
+      </TableView>
+    </Flex>
+  );
+};
+
+export default ScriptManualList;
diff --git a/ui.frontend/src/components/ScriptMockList.tsx b/ui.frontend/src/components/ScriptMockList.tsx
index f0f3dd80..16fd1f86 100644
--- a/ui.frontend/src/components/ScriptMockList.tsx
+++ b/ui.frontend/src/components/ScriptMockList.tsx
@@ -1,37 +1,19 @@
 import { Button, ButtonGroup, Cell, Column, Content, Flex, IllustratedMessage, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Settings from '@spectrum-icons/workflow/Settings';
-import React, { useCallback, useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { useAppState } from '../hooks/app.ts';
-import { toastRequest } from '../utils/api';
-import { InstanceType, ScriptOutput, ScriptType, instancePrefix } from '../utils/api.types';
+import { useAppState } from '../hooks/app';
+import { useScripts } from '../hooks/script';
+import { InstanceType, ScriptType, instancePrefix } from '../utils/api.types';
 import ScriptsMockHelpButton from './ScriptsMockHelpButton.tsx';
+import React from "react";
 
 const ScriptMockList: React.FC = () => {
   const type = ScriptType.MOCK;
-  const appState = useAppState();
-  const [scripts, setScripts] = useState<ScriptOutput | null>(null);
+  const {scripts, loading} = useScripts(type);
   const scriptCount = scripts?.list?.length ?? 0;
-  const [loading, setLoading] = useState<boolean>(true);
   const navigate = useNavigate();
-
-  const loadScripts = useCallback(() => {
-    setLoading(true);
-    toastRequest<ScriptOutput>({
-      method: 'GET',
-      url: `/apps/acm/api/script.json?type=${type}`,
-      operation: `Script mocks loading`,
-      positive: false,
-    })
-      .then((data) => setScripts(data.data.data))
-      .catch((error) => console.error(`Script mocks loading (${type}) error:`, error))
-      .finally(() => setLoading(false));
-  }, [type]);
-
-  useEffect(() => {
-    loadScripts();
-  }, [type, loadScripts]);
+  const appState = useAppState();
 
   const renderEmptyState = () => (
     <IllustratedMessage>
diff --git a/ui.frontend/src/hooks/script.ts b/ui.frontend/src/hooks/script.ts
new file mode 100644
index 00000000..4a3b3ecd
--- /dev/null
+++ b/ui.frontend/src/hooks/script.ts
@@ -0,0 +1,27 @@
+import { useCallback, useEffect, useState } from 'react';
+import { toastRequest } from '../utils/api';
+import { ScriptOutput, ScriptType } from '../utils/api.types';
+
+export function useScripts(type: ScriptType) {
+    const [scripts, setScripts] = useState<ScriptOutput | null>(null);
+    const [loading, setLoading] = useState<boolean>(true);
+
+    const loadScripts = useCallback(() => {
+        setLoading(true);
+        toastRequest<ScriptOutput>({
+            method: 'GET',
+            url: `/apps/acm/api/script.json?type=${type}`,
+            operation: `Scripts loading (${type.toString().toLowerCase()})`,
+            positive: false,
+        })
+            .then((data) => setScripts(data.data.data))
+            .catch((error) => console.error(`Scripts loading (${type}) error:`, error))
+            .finally(() => setLoading(false));
+    }, [type]);
+
+    useEffect(() => {
+        loadScripts();
+    }, [type, loadScripts]);
+
+    return { scripts, loading };
+}
\ No newline at end of file
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index 895ea6ec..e50a974b 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -4,13 +4,14 @@ import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
 import Extension from '@spectrum-icons/workflow/Extension';
 import FlashOn from '@spectrum-icons/workflow/FlashOn';
 import Hand from '@spectrum-icons/workflow/Hand';
-import ScriptListRich from '../components/ScriptListRich.tsx';
+import ScriptListRich from '../components/ScriptListRich';
 import { useAppState } from '../hooks/app.ts';
 import { useNavigationTab } from '../hooks/navigation';
 import { ScriptType } from '../utils/api.types';
 import styles from './ScriptsPage.module.css';
-import ScriptMockList from "../components/ScriptMockList.tsx";
-import ScriptExtensionList from "../components/ScriptExtensionList.tsx";
+import ScriptMockList from "../components/ScriptMockList";
+import ScriptExtensionList from "../components/ScriptExtensionList";
+import ScriptManualList from "../components/ScriptManualList";
 
 const ScriptsPage = () => {
   const appState = useAppState();
@@ -45,7 +46,7 @@ const ScriptsPage = () => {
         </TabList>
         <TabPanels flex="1" UNSAFE_style={{ display: 'flex' }}>
           <Item key="manual" aria-label="Manual">
-            <ScriptListRich type={ScriptType.MANUAL} />
+            <ScriptManualList/>
           </Item>
           <Item key="enabled" aria-label="Enabled">
             <ScriptListRich type={ScriptType.ENABLED} />

From a787a3d58f60ad08abc05ed6af74abf0a1486753 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 30 Jul 2025 22:04:16 +0200
Subject: [PATCH 03/83] Script lists inlined

---
 .../src/components/ScriptDisabledList.tsx     | 136 ++++++++++++++++++
 ...riptListRich.tsx => ScriptEnabledList.tsx} |  69 +++------
 .../components/ScriptExecutorStatusLight.tsx  |  27 ++++
 .../src/components/ScriptExtensionList.tsx    |   6 +-
 .../src/components/ScriptManualList.tsx       |  31 +---
 ui.frontend/src/components/ScriptMockList.tsx |   4 +-
 ui.frontend/src/hooks/script.ts               |  38 ++---
 ui.frontend/src/pages/ScriptsPage.tsx         |  20 +--
 8 files changed, 220 insertions(+), 111 deletions(-)
 create mode 100644 ui.frontend/src/components/ScriptDisabledList.tsx
 rename ui.frontend/src/components/{ScriptListRich.tsx => ScriptEnabledList.tsx} (66%)
 create mode 100644 ui.frontend/src/components/ScriptExecutorStatusLight.tsx

diff --git a/ui.frontend/src/components/ScriptDisabledList.tsx b/ui.frontend/src/components/ScriptDisabledList.tsx
new file mode 100644
index 00000000..c9a6449b
--- /dev/null
+++ b/ui.frontend/src/components/ScriptDisabledList.tsx
@@ -0,0 +1,136 @@
+import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
+import { Key, Selection } from '@react-types/shared';
+import NotFound from '@spectrum-icons/illustrations/NotFound';
+import Magnify from '@spectrum-icons/workflow/Magnify';
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAppState } from '../hooks/app';
+import { useFormatter } from '../hooks/formatter';
+import { useScripts } from '../hooks/script';
+import { InstanceRole, isExecutionNegative, ScriptType } from '../utils/api.types';
+import DateExplained from './DateExplained';
+import ExecutionStatsBadge from './ExecutionStatsBadge';
+import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
+import ScriptSynchronizeButton from './ScriptSynchronizeButton';
+import ScriptToggleButton from './ScriptToggleButton';
+import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
+import UserInfo from './UserInfo';
+
+const ScriptDisabledList: React.FC = () => {
+  const type = ScriptType.DISABLED;
+  const appState = useAppState();
+  const navigate = useNavigate();
+  const formatter = useFormatter();
+
+  const { scripts, loading, loadScripts } = useScripts(type);
+  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
+  const selectedIds = (selectedKeys: Selection): string[] => {
+    if (selectedKeys === 'all') {
+      return scripts?.list.map((script) => script.id) || [];
+    } else {
+      return Array.from(selectedKeys as Set<Key>).map((key) => key.toString());
+    }
+  };
+
+  const renderEmptyState = () => (
+    <IllustratedMessage>
+      <NotFound />
+      <Content>No scripts found</Content>
+    </IllustratedMessage>
+  );
+
+  if (scripts === null || loading) {
+    return (
+      <Flex flex="1" justifyContent="center" alignItems="center">
+        <ProgressBar label="Loading..." isIndeterminate />
+      </Flex>
+    );
+  }
+
+  return (
+    <Flex direction="column" flex="1" gap="size-200" marginY="size-100">
+      <View>
+        <Flex direction="row" justifyContent="space-between" alignItems="center">
+          <Flex flex="1" alignItems="center">
+            <ButtonGroup>
+              <ScriptToggleButton type={type} selectedKeys={selectedIds(selectedKeys)} onToggle={loadScripts} />
+              {appState.instanceSettings.role == InstanceRole.AUTHOR && <ScriptSynchronizeButton selectedKeys={selectedIds(selectedKeys)} onSync={loadScripts} />}
+            </ButtonGroup>
+          </Flex>
+          <Flex flex="1" justifyContent="center" alignItems="center">
+            <ScriptExecutorStatusLight />
+          </Flex>
+          <Flex flex="1" justifyContent="end" alignItems="center">
+            <ScriptsAutomaticHelpButton />
+          </Flex>
+        </Flex>
+      </View>
+      <TableView
+        flex="1"
+        aria-label={`Script list (${type})`}
+        selectionMode={'multiple'}
+        selectedKeys={selectedKeys}
+        onSelectionChange={setSelectedKeys}
+        renderEmptyState={renderEmptyState}
+        onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
+      >
+        <TableHeader>
+          <Column width="4fr">Name</Column>
+          <Column width="5fr">Last Execution</Column>
+          <Column width="3fr">
+            <Text>Average Duration</Text>
+            <ContextualHelp variant="info">
+              <Heading>Explanation</Heading>
+              <Content>Duration is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
+            </ContextualHelp>
+          </Column>
+          <Column width="2fr" align="end">
+            <Text>Success Rate</Text>
+            <ContextualHelp variant="info">
+              <Heading>Explanation</Heading>
+              <Content>Success rate is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
+            </ContextualHelp>
+          </Column>
+        </TableHeader>
+        <TableBody>
+          {(scripts.list || []).map((script) => {
+            const scriptStats = scripts.stats.find((stat) => stat.path === script.id)!;
+            const lastExecution = scriptStats?.lastExecution;
+
+            return (
+              <Row key={script.id}>
+                <Cell>{script.name}</Cell>
+                <Cell>
+                  <Flex alignItems="center" gap="size-100">
+                    {lastExecution ? (
+                      <>
+                        <Button variant={isExecutionNegative(lastExecution.status) ? 'negative' : 'secondary'} onPress={() => navigate(`/executions/view/${encodeURIComponent(lastExecution.id)}`)} aria-label="View Execution">
+                          <Magnify />
+                        </Button>
+                        <Text>
+                          <DateExplained value={lastExecution.startDate} />
+                        </Text>
+                        <Text>by</Text>
+                        <UserInfo id={lastExecution.userId} />
+                      </>
+                    ) : (
+                      <Text>&mdash;</Text>
+                    )}
+                  </Flex>
+                </Cell>
+                <Cell>
+                  <Text>{lastExecution ? formatter.duration(scriptStats.averageDuration) : <>&mdash;</>}</Text>
+                </Cell>
+                <Cell>
+                  <ExecutionStatsBadge stats={scriptStats} />
+                </Cell>
+              </Row>
+            );
+          })}
+        </TableBody>
+      </TableView>
+    </Flex>
+  );
+};
+
+export default ScriptDisabledList;
diff --git a/ui.frontend/src/components/ScriptListRich.tsx b/ui.frontend/src/components/ScriptEnabledList.tsx
similarity index 66%
rename from ui.frontend/src/components/ScriptListRich.tsx
rename to ui.frontend/src/components/ScriptEnabledList.tsx
index 40891cef..42a7cb0c 100644
--- a/ui.frontend/src/components/ScriptListRich.tsx
+++ b/ui.frontend/src/components/ScriptEnabledList.tsx
@@ -1,51 +1,30 @@
-import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, Link, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
+import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import { Key, Selection } from '@react-types/shared';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Magnify from '@spectrum-icons/workflow/Magnify';
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useFormatter } from '../hooks/formatter';
-import { toastRequest } from '../utils/api';
-import { InstanceRole, isExecutionNegative, ScriptOutput, ScriptType } from '../utils/api.types';
+import { useScripts } from '../hooks/script';
+import { InstanceRole, isExecutionNegative, ScriptType } from '../utils/api.types';
 import DateExplained from './DateExplained';
 import ExecutionStatsBadge from './ExecutionStatsBadge';
+import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
 import ScriptSynchronizeButton from './ScriptSynchronizeButton';
 import ScriptToggleButton from './ScriptToggleButton';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
-import ScriptsManualHelpButton from './ScriptsManualHelpButton';
-import UserInfo from './UserInfo.tsx';
+import UserInfo from './UserInfo';
 
-type ScriptListRichProps = {
-  type: ScriptType;
-};
-
-const ScriptListRich: React.FC<ScriptListRichProps> = ({ type }) => {
+const ScriptEnabledList: React.FC = () => {
+  const type = ScriptType.ENABLED;
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
 
-  const [scripts, setScripts] = useState<ScriptOutput | null>(null);
-  const [loading, setLoading] = useState<boolean>(true);
-  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
-
-  const loadScripts = useCallback(() => {
-    setLoading(true);
-    toastRequest<ScriptOutput>({
-      method: 'GET',
-      url: `/apps/acm/api/script.json?type=${type}`,
-      operation: `Scripts loading (${type.toString().toLowerCase()})`,
-      positive: false,
-    })
-      .then((data) => setScripts(data.data.data))
-      .catch((error) => console.error(`Scripts loading (${type}) error:`, error))
-      .finally(() => setLoading(false));
-  }, [type]);
-
-  useEffect(() => {
-    loadScripts();
-  }, [type, loadScripts]);
+  const { scripts, loading, loadScripts } = useScripts(type);
 
+  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
   const selectedIds = (selectedKeys: Selection): string[] => {
     if (selectedKeys === 'all') {
       return scripts?.list.map((script) => script.id) || [];
@@ -75,38 +54,22 @@ const ScriptListRich: React.FC<ScriptListRichProps> = ({ type }) => {
         <Flex direction="row" justifyContent="space-between" alignItems="center">
           <Flex flex="1" alignItems="center">
             <ButtonGroup>
-              {type === ScriptType.ENABLED || type === ScriptType.DISABLED ? (
-                <>
-                  <ScriptToggleButton type={type} selectedKeys={selectedIds(selectedKeys)} onToggle={loadScripts} />
-                  {appState.instanceSettings.role == InstanceRole.AUTHOR && <ScriptSynchronizeButton selectedKeys={selectedIds(selectedKeys)} onSync={loadScripts} />}
-                </>
-              ) : null}
+              <ScriptToggleButton type={type} selectedKeys={selectedIds(selectedKeys)} onToggle={loadScripts} />
+              {appState.instanceSettings.role == InstanceRole.AUTHOR && <ScriptSynchronizeButton selectedKeys={selectedIds(selectedKeys)} onSync={loadScripts} />}
             </ButtonGroup>
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
-            <StatusLight variant={appState.healthStatus.healthy ? 'positive' : 'negative'}>
-              {appState.healthStatus.healthy ? (
-                <Text>Executor active</Text>
-              ) : (
-                <>
-                  <Text>Executor paused</Text>
-                  <Text>&nbsp;&mdash;&nbsp;</Text>
-                  <Link isQuiet onPress={() => navigate('/maintenance?tab=health-checker')}>
-                    See health issues
-                  </Link>
-                </>
-              )}
-            </StatusLight>
+            <ScriptExecutorStatusLight />
           </Flex>
           <Flex flex="1" justifyContent="end" alignItems="center">
-            {type === ScriptType.MANUAL ? <ScriptsManualHelpButton /> : <ScriptsAutomaticHelpButton />}
+            <ScriptsAutomaticHelpButton />
           </Flex>
         </Flex>
       </View>
       <TableView
         flex="1"
         aria-label={`Script list (${type})`}
-        selectionMode={type === ScriptType.MANUAL ? 'none' : 'multiple'}
+        selectionMode={'multiple'}
         selectedKeys={selectedKeys}
         onSelectionChange={setSelectedKeys}
         renderEmptyState={renderEmptyState}
@@ -171,4 +134,4 @@ const ScriptListRich: React.FC<ScriptListRichProps> = ({ type }) => {
   );
 };
 
-export default ScriptListRich;
+export default ScriptEnabledList;
diff --git a/ui.frontend/src/components/ScriptExecutorStatusLight.tsx b/ui.frontend/src/components/ScriptExecutorStatusLight.tsx
new file mode 100644
index 00000000..c77f2c2d
--- /dev/null
+++ b/ui.frontend/src/components/ScriptExecutorStatusLight.tsx
@@ -0,0 +1,27 @@
+import { Link, StatusLight, Text } from '@adobe/react-spectrum';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAppState } from '../hooks/app';
+
+const ScriptExecutorStatusLight: React.FC = () => {
+  const appState = useAppState();
+  const navigate = useNavigate();
+
+  return (
+    <StatusLight variant={appState.healthStatus.healthy ? 'positive' : 'negative'}>
+      {appState.healthStatus.healthy ? (
+        <Text>Executor active</Text>
+      ) : (
+        <>
+          <Text>Executor paused</Text>
+          <Text>&nbsp;&mdash;&nbsp;</Text>
+          <Link isQuiet onPress={() => navigate('/maintenance?tab=health-checker')}>
+            See health issues
+          </Link>
+        </>
+      )}
+    </StatusLight>
+  );
+};
+
+export default ScriptExecutorStatusLight;
diff --git a/ui.frontend/src/components/ScriptExtensionList.tsx b/ui.frontend/src/components/ScriptExtensionList.tsx
index 26637cbc..1ac090b9 100644
--- a/ui.frontend/src/components/ScriptExtensionList.tsx
+++ b/ui.frontend/src/components/ScriptExtensionList.tsx
@@ -1,14 +1,14 @@
 import { Cell, Column, Content, Flex, IllustratedMessage, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
+import React from 'react';
 import { useNavigate } from 'react-router-dom';
+import { useScripts } from '../hooks/script';
 import { ScriptType } from '../utils/api.types';
 import ScriptsExtensionHelpButton from './ScriptsExtensionHelpButton.tsx';
-import { useScripts } from '../hooks/script';
-import React from "react";
 
 const ScriptExtensionList: React.FC = () => {
   const type = ScriptType.EXTENSION;
-  const {scripts, loading } = useScripts(type);
+  const { scripts, loading } = useScripts(type);
   const scriptCount = scripts?.list?.length ?? 0;
   const navigate = useNavigate();
 
diff --git a/ui.frontend/src/components/ScriptManualList.tsx b/ui.frontend/src/components/ScriptManualList.tsx
index d3b76de4..5343df49 100644
--- a/ui.frontend/src/components/ScriptManualList.tsx
+++ b/ui.frontend/src/components/ScriptManualList.tsx
@@ -1,20 +1,21 @@
-import { Button, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, Link, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
+import { Button, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Magnify from '@spectrum-icons/workflow/Magnify';
+import React from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useFormatter } from '../hooks/formatter';
+import { useScripts } from '../hooks/script';
 import { isExecutionNegative, ScriptType } from '../utils/api.types';
 import DateExplained from './DateExplained';
 import ExecutionStatsBadge from './ExecutionStatsBadge';
+import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
 import ScriptsManualHelpButton from './ScriptsManualHelpButton';
 import UserInfo from './UserInfo';
-import React from "react";
-import {useScripts} from "../hooks/script";
 
 const ScriptManualList: React.FC = () => {
   const type = ScriptType.MANUAL;
-  const {scripts, loading} = useScripts(type);
+  const { scripts, loading } = useScripts(type);
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
@@ -42,32 +43,14 @@ const ScriptManualList: React.FC = () => {
             &nbsp;
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
-            <StatusLight variant={appState.healthStatus.healthy ? 'positive' : 'negative'}>
-              {appState.healthStatus.healthy ? (
-                <Text>Executor active</Text>
-              ) : (
-                <>
-                  <Text>Executor paused</Text>
-                  <Text>&nbsp;&mdash;&nbsp;</Text>
-                  <Link isQuiet onPress={() => navigate('/maintenance?tab=health-checker')}>
-                    See health issues
-                  </Link>
-                </>
-              )}
-            </StatusLight>
+            <ScriptExecutorStatusLight />
           </Flex>
           <Flex flex="1" justifyContent="end" alignItems="center">
             <ScriptsManualHelpButton />
           </Flex>
         </Flex>
       </View>
-      <TableView
-        flex="1"
-        aria-label={`Script list (${type})`}
-        selectionMode={'none'}
-        renderEmptyState={renderEmptyState}
-        onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
-      >
+      <TableView flex="1" aria-label={`Script list (${type})`} selectionMode={'none'} renderEmptyState={renderEmptyState} onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}>
         <TableHeader>
           <Column width="4fr">Name</Column>
           <Column width="5fr">Last Execution</Column>
diff --git a/ui.frontend/src/components/ScriptMockList.tsx b/ui.frontend/src/components/ScriptMockList.tsx
index 16fd1f86..f355e4ff 100644
--- a/ui.frontend/src/components/ScriptMockList.tsx
+++ b/ui.frontend/src/components/ScriptMockList.tsx
@@ -1,16 +1,16 @@
 import { Button, ButtonGroup, Cell, Column, Content, Flex, IllustratedMessage, ProgressBar, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Settings from '@spectrum-icons/workflow/Settings';
+import React from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useScripts } from '../hooks/script';
 import { InstanceType, ScriptType, instancePrefix } from '../utils/api.types';
 import ScriptsMockHelpButton from './ScriptsMockHelpButton.tsx';
-import React from "react";
 
 const ScriptMockList: React.FC = () => {
   const type = ScriptType.MOCK;
-  const {scripts, loading} = useScripts(type);
+  const { scripts, loading } = useScripts(type);
   const scriptCount = scripts?.list?.length ?? 0;
   const navigate = useNavigate();
   const appState = useAppState();
diff --git a/ui.frontend/src/hooks/script.ts b/ui.frontend/src/hooks/script.ts
index 4a3b3ecd..83d7e690 100644
--- a/ui.frontend/src/hooks/script.ts
+++ b/ui.frontend/src/hooks/script.ts
@@ -3,25 +3,25 @@ import { toastRequest } from '../utils/api';
 import { ScriptOutput, ScriptType } from '../utils/api.types';
 
 export function useScripts(type: ScriptType) {
-    const [scripts, setScripts] = useState<ScriptOutput | null>(null);
-    const [loading, setLoading] = useState<boolean>(true);
+  const [scripts, setScripts] = useState<ScriptOutput | null>(null);
+  const [loading, setLoading] = useState<boolean>(true);
 
-    const loadScripts = useCallback(() => {
-        setLoading(true);
-        toastRequest<ScriptOutput>({
-            method: 'GET',
-            url: `/apps/acm/api/script.json?type=${type}`,
-            operation: `Scripts loading (${type.toString().toLowerCase()})`,
-            positive: false,
-        })
-            .then((data) => setScripts(data.data.data))
-            .catch((error) => console.error(`Scripts loading (${type}) error:`, error))
-            .finally(() => setLoading(false));
-    }, [type]);
+  const loadScripts = useCallback(() => {
+    setLoading(true);
+    toastRequest<ScriptOutput>({
+      method: 'GET',
+      url: `/apps/acm/api/script.json?type=${type}`,
+      operation: `Scripts loading (${type.toString().toLowerCase()})`,
+      positive: false,
+    })
+      .then((data) => setScripts(data.data.data))
+      .catch((error) => console.error(`Scripts loading (${type}) error:`, error))
+      .finally(() => setLoading(false));
+  }, [type]);
 
-    useEffect(() => {
-        loadScripts();
-    }, [type, loadScripts]);
+  useEffect(() => {
+    loadScripts();
+  }, [type, loadScripts]);
 
-    return { scripts, loading };
-}
\ No newline at end of file
+  return { scripts, loading, loadScripts };
+}
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index e50a974b..ec2bba77 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -4,14 +4,14 @@ import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
 import Extension from '@spectrum-icons/workflow/Extension';
 import FlashOn from '@spectrum-icons/workflow/FlashOn';
 import Hand from '@spectrum-icons/workflow/Hand';
-import ScriptListRich from '../components/ScriptListRich';
+import ScriptDisabledList from '../components/ScriptDisabledList';
+import ScriptEnabledList from '../components/ScriptEnabledList';
+import ScriptExtensionList from '../components/ScriptExtensionList';
+import ScriptManualList from '../components/ScriptManualList';
+import ScriptMockList from '../components/ScriptMockList';
 import { useAppState } from '../hooks/app.ts';
 import { useNavigationTab } from '../hooks/navigation';
-import { ScriptType } from '../utils/api.types';
 import styles from './ScriptsPage.module.css';
-import ScriptMockList from "../components/ScriptMockList";
-import ScriptExtensionList from "../components/ScriptExtensionList";
-import ScriptManualList from "../components/ScriptManualList";
 
 const ScriptsPage = () => {
   const appState = useAppState();
@@ -46,21 +46,21 @@ const ScriptsPage = () => {
         </TabList>
         <TabPanels flex="1" UNSAFE_style={{ display: 'flex' }}>
           <Item key="manual" aria-label="Manual">
-            <ScriptManualList/>
+            <ScriptManualList />
           </Item>
           <Item key="enabled" aria-label="Enabled">
-            <ScriptListRich type={ScriptType.ENABLED} />
+            <ScriptEnabledList />
           </Item>
           <Item key="disabled" aria-label="Disabled">
-            <ScriptListRich type={ScriptType.DISABLED} />
+            <ScriptDisabledList />
           </Item>
           {appState.mockStatus.enabled ? (
             <Item key="mock" aria-label="Mock">
-              <ScriptMockList/>
+              <ScriptMockList />
             </Item>
           ) : null}
           <Item key="extension" aria-label="Extension">
-            <ScriptExtensionList/>
+            <ScriptExtensionList />
           </Item>
         </TabPanels>
       </Tabs>

From c1e4bbdb448fa883166ed7d3a53d5ecde1ffcdba Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 30 Jul 2025 23:01:34 +0200
Subject: [PATCH 04/83] Scripts GUI + content

---
 README.md                                     |  6 +-
 .../dev/vml/es/acm/core/AcmConstants.java     |  3 +
 .../dev/vml/es/acm/core/code/Conditions.java  |  2 +-
 .../vml/es/acm/core/code/ExecutableUtils.java |  4 +-
 .../vml/es/acm/core/code/ExecutionQueue.java  |  7 +-
 .../dev/vml/es/acm/core/script/Script.java    |  2 +-
 .../es/acm/core/script/ScriptRepository.java  | 92 +------------------
 .../es/acm/core/script/ScriptScheduler.java   | 15 ++-
 .../vml/es/acm/core/script/ScriptStats.java   |  7 +-
 .../vml/es/acm/core/script/ScriptType.java    | 14 +--
 .../es/acm/core/servlet/QueueCodeServlet.java |  9 +-
 .../es/acm/core/servlet/ScriptServlet.java    | 65 +------------
 .../vml/es/acm/core/util/ResourceUtils.java   |  3 +-
 .../main/content/META-INF/vault/filter.xml    |  6 +-
 .../disabled/example/ACME-1_failing.groovy    |  9 --
 .../disabled/example/ACME-2_changed.groovy    |  7 --
 .../script/auto/enabled/example/.content.xml  |  3 -
 .../script/{auto => boot}/.content.xml        |  0
 .../disabled => boot/example}/.content.xml    |  0
 .../example/ACME-100_acl.groovy               |  0
 .../example/ACME-21_every-hour.groovy         |  0
 .../example/ACME-22_every-day.groovy          |  0
 .../example/ACME-23_every-week.groovy         |  0
 .../example/ACME-24_every-month.groovy        |  0
 .../example => schedule}/.content.xml         |  0
 .../enabled => schedule/example}/.content.xml |  0
 .../example/ACME-20_once.groovy               |  0
 ...riptEnabledList.tsx => ScriptBootList.tsx} |  7 +-
 ...isabledList.tsx => ScriptScheduleList.tsx} |  7 +-
 .../src/components/ScriptToggleButton.tsx     | 12 +--
 ui.frontend/src/pages/ScriptsPage.tsx         | 30 +++---
 ui.frontend/src/utils/api.types.ts            |  5 +-
 32 files changed, 70 insertions(+), 245 deletions(-)
 delete mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-1_failing.groovy
 delete mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-2_changed.groovy
 delete mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/.content.xml
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto => boot}/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/disabled => boot/example}/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => boot}/example/ACME-100_acl.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => boot}/example/ACME-21_every-hour.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => boot}/example/ACME-22_every-day.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => boot}/example/ACME-23_every-week.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => boot}/example/ACME-24_every-month.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/disabled/example => schedule}/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => schedule/example}/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{auto/enabled => schedule}/example/ACME-20_once.groovy (100%)
 rename ui.frontend/src/components/{ScriptEnabledList.tsx => ScriptBootList.tsx} (98%)
 rename ui.frontend/src/components/{ScriptDisabledList.tsx => ScriptScheduleList.tsx} (97%)

diff --git a/README.md b/README.md
index 4afac0a8..8654d36c 100644
--- a/README.md
+++ b/README.md
@@ -191,8 +191,8 @@ The ACM Console is interactive and offers the following features:
 Content scripts in ACM are Groovy scripts that can be used to automate various tasks in AEM. 
 These scripts can be placed in specific locations within the AEM repository to control their execution behavior.
 
-- `/conf/acm/settings/script/auto/enabled/{project}`: Automatically executed scripts that are enabled and awaiting execution if `canRun` conditions are met.
-- `/conf/acm/settings/script/auto/disabled/{project}`: Automatically executed scripts that are disabled, serving as a safety guard to quickly disable scripts under unexpected circumstances.
+- `/conf/acm/settings/script/boot/{project}`: Automatically executed scripts on instance boot, usually run once after deployment. Specific conditions could be narrowed by 'canRun()' method.
+- `/conf/acm/settings/script/schedule/{project}`: Automatically executed scripts on schedule every minute. Specific conditions could be narrowed by 'canRun()' method.
 - `/conf/acm/settings/script/manual/{project}`: Manually executed scripts, run under specific circumstances by platform administrators.
 
 #### Minimal example
@@ -235,7 +235,7 @@ void doRun() {
 ```
 
 The `describeRun()` method is used to define the arguments that can be passed to the script.
-The `args` service is used to define the arguments that can be passed to the script.
+The `arguments` service is used to define the arguments that can be passed to the script.
 When the script is executed, the arguments are passed to the `doRun()` method.
 
 There are many built-in argument types to use handling different types of data like string, boolean, number, date, file, etc. Just check `arguments` [service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java) for more details.
diff --git a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
index 040d7cfc..118a9c0d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
+++ b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
@@ -2,6 +2,9 @@
 
 public class AcmConstants {
 
+    // TODO move scripts and stuff here
+    public static final String APPS_ROOT = "/apps/acm";
+
     public static final String SETTINGS_ROOT = "/conf/acm/settings";
 
     public static final String VAR_ROOT = "/var/acm";
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
index 45e18bb5..1bd36118 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
@@ -610,7 +610,7 @@ public boolean isConsole() {
     }
 
     public boolean isAutomaticScript() {
-        return StringUtils.startsWith(executableId(), ScriptType.ENABLED.root() + "/");
+        return StringUtils.startsWith(executableId(), ScriptType.SCHEDULE.root() + "/");
     }
 
     public boolean isManualScript() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
index 94df3382..1ec5a16e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
@@ -15,8 +15,8 @@ public static String nameById(String id) {
         if (Executable.ID_CONSOLE.equals(id)) {
             return "Console";
         }
-        if (StringUtils.startsWith(id, ScriptType.ENABLED.root() + "/")) {
-            return String.format("Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.ENABLED.root() + "/"));
+        if (StringUtils.startsWith(id, ScriptType.SCHEDULE.root() + "/")) {
+            return String.format("Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.SCHEDULE.root() + "/"));
         }
         if (StringUtils.startsWith(id, ScriptType.MANUAL.root() + "/")) {
             return String.format("Manual Script '%s'", StringUtils.removeStart(id, ScriptType.MANUAL.root() + "/"));
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index e5418a70..6cfad90e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -67,8 +67,7 @@ protected void deactivate() {
         }
     }
 
-    public Execution submit(ExecutionContextOptions contextOptions, Executable executable)
-            throws AcmException {
+    public Execution submit(ExecutionContextOptions contextOptions, Executable executable) throws AcmException {
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
         jobProps.putAll(Code.toJobProps(executable));
@@ -76,7 +75,9 @@ public Execution submit(ExecutionContextOptions contextOptions, Executable execu
 
         Job job = jobManager.addJob(TOPIC, jobProps);
         if (job == null) {
-            throw new AcmException(String.format("Execution of executable '%s' cannot be queued because manager refused to add a job!", executable.getId()));
+            throw new AcmException(String.format(
+                    "Execution of executable '%s' cannot be queued because manager refused to add a job!",
+                    executable.getId()));
         }
         return new QueuedExecution(executor, job);
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
index 1599ef4d..4e3d0ef0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
@@ -67,7 +67,7 @@ public String getPath() {
     }
 
     public ScriptType getType() {
-        return ScriptType.byPath(getPath()).orElse(ScriptType.DISABLED);
+        return ScriptType.byPath(getPath()).orElse(ScriptType.BOOT);
     }
 
     public InputStream readContent() throws ScriptException {
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index d1a9f7d8..3cf03bed 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -6,23 +6,15 @@
 import dev.vml.es.acm.core.util.ResourceUtils;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
-import org.apache.sling.api.resource.ResourceUtil;
-import org.apache.sling.jcr.resource.api.JcrResourceConstants;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class ScriptRepository {
 
+    // TODO /conf/acm/settings/script => /apps/acm/script
     public static final String ROOT = AcmConstants.SETTINGS_ROOT + "/script";
 
-    private static final Logger LOG = LoggerFactory.getLogger(ScriptRepository.class);
-
     private final ResourceResolver resourceResolver;
 
     public ScriptRepository(ResourceResolver resourceResolver) {
@@ -55,86 +47,4 @@ public Stream<Script> readAll(List<String> ids) {
     private Resource getOrCreateRoot(ScriptType type) throws AcmException {
         return ResourceUtils.makeFolders(resourceResolver, type.root());
     }
-
-    public void enable(String path) throws AcmException {
-        Script script = read(path).orElse(null);
-        if (script == null) {
-            throw new AcmException(String.format("Script at path '%s' does not exist!", path));
-        }
-        if (script.getType() != ScriptType.DISABLED) {
-            throw new AcmException(String.format("Script at path '%s' is not disabled!", path));
-        }
-        try {
-            String sourcePath = script.getPath();
-            String targetPath = ScriptType.ENABLED.enforcePath(path);
-            ensureParentFolder(targetPath);
-            ResourceUtils.move(resourceResolver, sourcePath, targetPath);
-        } catch (PersistenceException e) {
-            throw new AcmException(String.format("Cannot enable script at path '%s'!", path), e);
-        }
-    }
-
-    public void disable(String path) throws AcmException {
-        Script script = read(path).orElse(null);
-        if (script == null) {
-            throw new AcmException(String.format("Script at path '%s' does not exist!", path));
-        }
-        if (script.getType() != ScriptType.ENABLED) {
-            throw new AcmException(String.format("Script at path '%s' is not enabled!", path));
-        }
-        try {
-            String sourcePath = script.getPath();
-            String targetPath = ScriptType.DISABLED.enforcePath(path);
-            ensureParentFolder(targetPath);
-            ResourceUtils.move(resourceResolver, sourcePath, targetPath);
-        } catch (PersistenceException e) {
-            throw new AcmException(String.format("Cannot disable script at path '%s'!", path), e);
-        }
-    }
-
-    private void ensureParentFolder(String path) {
-        try {
-            String parentPath = StringUtils.substringBeforeLast(path, "/");
-            ResourceUtil.getOrCreateResource(
-                    resourceResolver,
-                    parentPath,
-                    JcrResourceConstants.NT_SLING_ORDERED_FOLDER,
-                    JcrResourceConstants.NT_SLING_ORDERED_FOLDER,
-                    true);
-        } catch (PersistenceException e) {
-            throw new AcmException(String.format("Cannot create parent folder for path '%s'!", path), e);
-        }
-    }
-
-    public void clean() {
-        List<String> disabledPaths =
-                findAll(ScriptType.DISABLED).map(Script::getPath).collect(Collectors.toList());
-        List<Script> duplicatedScripts = findAll(ScriptType.ENABLED)
-                .filter(s -> disabledPaths.contains(ScriptType.DISABLED.enforcePath(s.getPath())))
-                .collect(Collectors.toList());
-
-        if (!duplicatedScripts.isEmpty()) {
-            String duplicatedPaths =
-                    duplicatedScripts.stream().map(Script::getPath).collect(Collectors.joining(","));
-            LOG.warn("Detected script duplicates ({}): {}", duplicatedScripts.size(), duplicatedPaths);
-            LOG.warn(
-                    "Scripts cannot be enabled & disabled at the same time so check Vault filters of package with scripts deployed to instance.");
-            duplicatedScripts.forEach(s -> delete(s.getPath()));
-            LOG.info("Removed script duplicates ({}): {}", duplicatedScripts.size(), duplicatedPaths);
-        }
-    }
-
-    private void delete(String path) {
-        Resource resource = resourceResolver.getResource(path);
-        if (resource == null) {
-            throw new AcmException(
-                    String.format("Cannot delete script at path '%s' as it does not exist already!", path));
-        }
-        try {
-            resourceResolver.delete(resource);
-            resourceResolver.commit();
-        } catch (PersistenceException e) {
-            throw new AcmException(String.format("Cannot delete script at path '%s'!", path), e);
-        }
-    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
index 43608f28..e8fd5699 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
@@ -9,7 +9,6 @@
 import java.text.ParseException;
 import java.util.Date;
 import java.util.concurrent.atomic.AtomicLong;
-
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceResolverFactory;
@@ -42,9 +41,8 @@ public class ScriptScheduler implements Runnable {
         @AttributeDefinition(
                 name = "Scheduler Expression",
                 description =
-                        "How often the scripts should be executed. Default is every 30 seconds (0/30 * * * * ?). Quartz cron expression format.",
-                defaultValue = "0/30 * * * * ?")
-        String scheduler_expression() default "0/30 * * * * ?";
+                        "How often the scripts should be executed. Default is every minute (0 * * * * ?). Quartz cron expression format.")
+        String scheduler_expression() default "0 * * * * ?";
 
         @AttributeDefinition(
                 name = "User Impersonation ID",
@@ -102,15 +100,14 @@ public void run() {
             return;
         }
 
-        String userId = StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
+        String userId =
+                StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
         ExecutionContextOptions contextOptions = new ExecutionContextOptions(ExecutionMode.RUN, userId);
         try (ResourceResolver resourceResolver =
                 ResourceUtils.contentResolver(resourceResolverFactory, contextOptions.getUserId())) {
             ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
 
-            scriptRepository.clean();
-
-            scriptRepository.findAll(ScriptType.ENABLED).forEach(script -> {
+            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> {
                 if (checkScript(script, resourceResolver)) {
                     submitScript(script, contextOptions);
                 }
@@ -124,7 +121,7 @@ public void run() {
 
     private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         try (ExecutionContext context =
-                     executor.createContext(ExecutionId.generate(), ExecutionMode.CHECK, script, resourceResolver)) {
+                executor.createContext(ExecutionId.generate(), ExecutionMode.CHECK, script, resourceResolver)) {
             Execution execution = executor.execute(context);
             LOG.debug("Script checked '{}'", execution);
             return execution.getStatus() != ExecutionStatus.SKIPPED;
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptStats.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptStats.java
index ecccef5a..efac0e12 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptStats.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptStats.java
@@ -39,14 +39,9 @@ public static ScriptStats forCompletedByPath(ResourceResolver resourceResolver,
 
         ExecutionHistory history = new ExecutionHistory(resourceResolver);
         ExecutionQuery query = new ExecutionQuery();
+        query.setExecutableId(path);
         query.setStatuses(ExecutionStatus.completed());
 
-        if (scriptType == ScriptType.MANUAL || scriptType == ScriptType.ENABLED) {
-            query.setExecutableId(path);
-        } else if (scriptType == ScriptType.DISABLED) {
-            query.setExecutableId(ScriptType.ENABLED.enforcePath(path));
-        }
-
         List<ExecutionSummary> executions =
                 history.findAllSummaries(query).limit(limit).collect(Collectors.toList());
         Integer length = executions.size();
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
index 2a05465d..a4151b58 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
@@ -6,8 +6,8 @@
 
 public enum ScriptType {
     MANUAL(ScriptRepository.ROOT + "/manual"),
-    ENABLED(ScriptRepository.ROOT + "/auto/enabled"),
-    DISABLED(ScriptRepository.ROOT + "/auto/disabled"),
+    SCHEDULE(ScriptRepository.ROOT + "/schedule"),
+    BOOT(ScriptRepository.ROOT + "/boot"),
     MOCK(ScriptRepository.ROOT + "/mock"),
     EXTENSION(ScriptRepository.ROOT + "/extension"),
     TEMPLATE(ScriptRepository.ROOT + "/template");
@@ -30,16 +30,8 @@ public static Optional<ScriptType> byPath(String path) {
                 .findFirst();
     }
 
-    public String enforcePath(String path) {
-        String subPath = path;
-        for (ScriptType value : values()) {
-            subPath = StringUtils.removeStart(subPath, value.root() + "/");
-        }
-        return root + "/" + subPath;
-    }
-
     public boolean statsSupported() {
-        return this == MANUAL || this == ENABLED || this == DISABLED;
+        return this == MANUAL || this == SCHEDULE || this == BOOT;
     }
 
     public String root() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index bc9e3573..3e29c58a 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -55,8 +55,8 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
 
             Code code = input.getCode();
 
-            try (ExecutionContext context =
-                         executor.createContext(ExecutionId.generate(), ExecutionMode.CHECK, input.getCode(), request.getResourceResolver())) {
+            try (ExecutionContext context = executor.createContext(
+                    ExecutionId.generate(), ExecutionMode.CHECK, input.getCode(), request.getResourceResolver())) {
                 Execution checkExecution = executor.execute(context);
                 if (checkExecution.getStatus() == ExecutionStatus.SKIPPED) {
                     QueueOutput output = new QueueOutput(Collections.singletonList(checkExecution));
@@ -65,7 +65,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
                 }
             } catch (Exception e) {
                 LOG.error("Code execution cannot be checked!", e);
-                respondJson(response, badRequest(String.format("Code execution cannot be checked! %s", e.getMessage()).trim()));
+                respondJson(
+                        response,
+                        badRequest(String.format("Code execution cannot be checked! %s", e.getMessage())
+                                .trim()));
                 return;
             }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
index 94f322a0..d0876f03 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
@@ -4,15 +4,12 @@
 import static dev.vml.es.acm.core.util.ServletResult.ok;
 import static dev.vml.es.acm.core.util.ServletUtils.*;
 
-import com.day.cq.replication.Replicator;
 import dev.vml.es.acm.core.gui.SpaSettings;
-import dev.vml.es.acm.core.replication.Activator;
 import dev.vml.es.acm.core.script.Script;
 import dev.vml.es.acm.core.script.ScriptRepository;
 import dev.vml.es.acm.core.script.ScriptStats;
 import dev.vml.es.acm.core.script.ScriptType;
 import java.io.IOException;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -44,25 +41,8 @@ public class ScriptServlet extends SlingAllMethodsServlet {
 
     private static final String TYPE_PARAM = "type";
 
-    private static final String ACTION_PARAM = "action";
-
     private static final String STATS_LIMIT_PARAM = "statsLimit";
 
-    private enum Action {
-        ENABLE,
-        DISABLE,
-        SYNC_ALL;
-
-        public static Optional<Action> of(String name) {
-            return Arrays.stream(Action.values())
-                    .filter(a -> a.name().equalsIgnoreCase(name))
-                    .findFirst();
-        }
-    }
-
-    @Reference
-    private Replicator replicator;
-
     @Reference
     private SpaSettings spaSettings;
 
@@ -85,10 +65,10 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
             if (ids != null) {
                 scripts = repository.readAll(ids).sorted().collect(Collectors.toList());
             } else {
-                ScriptType type =
-                        ScriptType.of(stringParam(request, TYPE_PARAM)).orElse(null);
+                String typeValue = stringParam(request, TYPE_PARAM);
+                ScriptType type = ScriptType.of(typeValue).orElse(null);
                 if (type == null) {
-                    respondJson(response, error("Script type parameter is not specified!"));
+                    respondJson(response, error(String.format("Script type '%s' is not supported!", typeValue)));
                     return;
                 }
                 scripts = repository.findAll(type).sorted().collect(Collectors.toList());
@@ -108,43 +88,4 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
                             .trim()));
         }
     }
-
-    @Override
-    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
-        Optional<Action> action = Action.of(stringParam(request, ACTION_PARAM));
-        if (!action.isPresent()) {
-            respondJson(response, error("Invalid action parameter! Must be either 'enable', 'disable' or 'sync_all'"));
-            return;
-        }
-
-        List<String> paths = stringsParam(request, ID_PARAM);
-        if ((action.get() == Action.ENABLE || action.get() == Action.DISABLE) && paths == null) {
-            respondJson(response, error("Script path parameter is not specified!"));
-            return;
-        }
-
-        try {
-            ScriptRepository repository = new ScriptRepository(request.getResourceResolver());
-            Activator activator = new Activator(request.getResourceResolver(), replicator);
-
-            switch (action.get()) {
-                case ENABLE:
-                    paths.forEach(repository::enable);
-                    respondJson(response, ok(String.format("%d script(s) enabled successfully", paths.size())));
-                    break;
-                case DISABLE:
-                    paths.forEach(repository::disable);
-                    respondJson(response, ok(String.format("%d script(s) disabled successfully", paths.size())));
-                    break;
-                case SYNC_ALL:
-                    repository.clean();
-                    activator.reactivateTree(ScriptRepository.ROOT);
-                    respondJson(response, ok("Scripts synchronized successfully"));
-                    break;
-            }
-        } catch (Exception e) {
-            LOG.error("Cannot perform script action", e);
-            respondJson(response, error(e.getMessage()));
-        }
-    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index 4b7582e6..f37a2aab 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -46,7 +46,8 @@ private static ResourceResolver serviceResolver(
         Map<String, Object> params = new HashMap<>();
         params.put(ResourceResolverFactory.SUBSERVICE, subservice.id());
 
-        boolean impersonation = StringUtils.isNotBlank(userImpersonationId) && !StringUtils.equals(subservice.userId, userImpersonationId);
+        boolean impersonation = StringUtils.isNotBlank(userImpersonationId)
+                && !StringUtils.equals(subservice.userId, userImpersonationId);
         if (!impersonation) {
             return resourceResolverFactory.getServiceResourceResolver(params);
         }
diff --git a/ui.content.example/src/main/content/META-INF/vault/filter.xml b/ui.content.example/src/main/content/META-INF/vault/filter.xml
index fc6f1d9f..e9e51daa 100644
--- a/ui.content.example/src/main/content/META-INF/vault/filter.xml
+++ b/ui.content.example/src/main/content/META-INF/vault/filter.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <workspaceFilter version="1.0">
-    <filter root="/conf/acm/settings/script/extension/example"/>
     <filter root="/conf/acm/settings/script/manual/example"/>
-    <filter root="/conf/acm/settings/script/auto/enabled/example"/>
-    <filter root="/conf/acm/settings/script/auto/disabled/example"/>
+    <filter root="/conf/acm/settings/script/boot/example"/>
+    <filter root="/conf/acm/settings/script/schedule/example"/>
+    <filter root="/conf/acm/settings/script/extension/example"/>
     <filter root="/conf/acm/settings/script/mock/example"/>
 </workspaceFilter>
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-1_failing.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-1_failing.groovy
deleted file mode 100644
index c3108ed8..00000000
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-1_failing.groovy
+++ /dev/null
@@ -1,9 +0,0 @@
-boolean canRun() {
-    return conditions.retry(3)
-}
-
-void doRun() {
-    println("I will fail soon...")
-    Thread.sleep(1000)
-    throw new RuntimeException("I am failing!")
-}
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-2_changed.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-2_changed.groovy
deleted file mode 100644
index 7e3ab5cf..00000000
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/ACME-2_changed.groovy
+++ /dev/null
@@ -1,7 +0,0 @@
-boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.changed()
-}
-
-void doRun() {
-    throw new RuntimeException("I should run when script content changes or when the instance changes after a failure!")
-}
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/.content.xml
deleted file mode 100644
index 491392d5..00000000
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/.content.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
-    jcr:primaryType="sling:Folder"/>
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-100_acl.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-100_acl.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-100_acl.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-100_acl.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-21_every-hour.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-21_every-hour.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-21_every-hour.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-22_every-day.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-22_every-day.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-22_every-day.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-23_every-week.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-23_every-week.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-23_every-week.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-24_every-month.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-24_every-month.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-24_every-month.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/disabled/example/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-20_once.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-20_once.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/auto/enabled/example/ACME-20_once.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-20_once.groovy
diff --git a/ui.frontend/src/components/ScriptEnabledList.tsx b/ui.frontend/src/components/ScriptBootList.tsx
similarity index 98%
rename from ui.frontend/src/components/ScriptEnabledList.tsx
rename to ui.frontend/src/components/ScriptBootList.tsx
index 42a7cb0c..51203ef2 100644
--- a/ui.frontend/src/components/ScriptEnabledList.tsx
+++ b/ui.frontend/src/components/ScriptBootList.tsx
@@ -16,14 +16,13 @@ import ScriptToggleButton from './ScriptToggleButton';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
 
-const ScriptEnabledList: React.FC = () => {
-  const type = ScriptType.ENABLED;
+const ScriptBootList: React.FC = () => {
+  const type = ScriptType.BOOT;
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
 
   const { scripts, loading, loadScripts } = useScripts(type);
-
   const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
   const selectedIds = (selectedKeys: Selection): string[] => {
     if (selectedKeys === 'all') {
@@ -134,4 +133,4 @@ const ScriptEnabledList: React.FC = () => {
   );
 };
 
-export default ScriptEnabledList;
+export default ScriptBootList;
diff --git a/ui.frontend/src/components/ScriptDisabledList.tsx b/ui.frontend/src/components/ScriptScheduleList.tsx
similarity index 97%
rename from ui.frontend/src/components/ScriptDisabledList.tsx
rename to ui.frontend/src/components/ScriptScheduleList.tsx
index c9a6449b..afc611f2 100644
--- a/ui.frontend/src/components/ScriptDisabledList.tsx
+++ b/ui.frontend/src/components/ScriptScheduleList.tsx
@@ -16,13 +16,14 @@ import ScriptToggleButton from './ScriptToggleButton';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
 
-const ScriptDisabledList: React.FC = () => {
-  const type = ScriptType.DISABLED;
+const ScriptScheduleList: React.FC = () => {
+  const type = ScriptType.SCHEDULE;
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
 
   const { scripts, loading, loadScripts } = useScripts(type);
+
   const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
   const selectedIds = (selectedKeys: Selection): string[] => {
     if (selectedKeys === 'all') {
@@ -133,4 +134,4 @@ const ScriptDisabledList: React.FC = () => {
   );
 };
 
-export default ScriptDisabledList;
+export default ScriptScheduleList;
diff --git a/ui.frontend/src/components/ScriptToggleButton.tsx b/ui.frontend/src/components/ScriptToggleButton.tsx
index c669896b..349a1601 100644
--- a/ui.frontend/src/components/ScriptToggleButton.tsx
+++ b/ui.frontend/src/components/ScriptToggleButton.tsx
@@ -6,7 +6,7 @@ import { toastRequest } from '../utils/api';
 import { ScriptType } from '../utils/api.types';
 
 type ScriptToggleButtonProps = {
-  type: ScriptType.ENABLED | ScriptType.DISABLED;
+  type: ScriptType.SCHEDULE | ScriptType.BOOT;
   selectedKeys: string[];
   onToggle: () => void;
 };
@@ -17,7 +17,7 @@ const ScriptToggleButton: React.FC<ScriptToggleButtonProps> = ({ type, selectedK
 
   const handleConfirm = async () => {
     setIsLoading(true);
-    const action = type === ScriptType.ENABLED ? 'disable' : 'enable';
+    const action = type === ScriptType.SCHEDULE ? 'disable' : 'enable';
     const ids = Array.from(selectedKeys);
 
     const params = new URLSearchParams();
@@ -47,7 +47,7 @@ const ScriptToggleButton: React.FC<ScriptToggleButtonProps> = ({ type, selectedK
       </Heading>
       <Divider />
       <Content>
-        {type === ScriptType.ENABLED ? (
+        {type === ScriptType.SCHEDULE ? (
           <Text>Disabling scripts will stop their automatic execution. To execute them again, you need to enable them or reinstall the package with scripts.</Text>
         ) : (
           <Text>Enabling scripts can cause changes in the repository and potential data loss. Ensure the script is ready to use. It is recommended to provide enabled scripts via a package, not manually.</Text>
@@ -68,9 +68,9 @@ const ScriptToggleButton: React.FC<ScriptToggleButtonProps> = ({ type, selectedK
 
   return (
     <DialogTrigger isOpen={toggleDialogOpen} onOpenChange={setToggleDialogOpen}>
-      <Button variant={type === ScriptType.ENABLED ? 'negative' : 'accent'} style="fill" isDisabled={selectedKeys.length === 0} onPress={() => setToggleDialogOpen(true)}>
-        {type === ScriptType.ENABLED ? <Cancel /> : <Checkmark />}
-        <Text>{type === ScriptType.ENABLED ? 'Disable' : 'Enable'}</Text>
+      <Button variant={type === ScriptType.SCHEDULE ? 'negative' : 'accent'} style="fill" isDisabled={selectedKeys.length === 0} onPress={() => setToggleDialogOpen(true)}>
+        {type === ScriptType.SCHEDULE ? <Cancel /> : <Checkmark />}
+        <Text>{type === ScriptType.SCHEDULE ? 'Disable' : 'Enable'}</Text>
       </Button>
       <Dialog>{renderToggleDialog()}</Dialog>
     </DialogTrigger>
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index ec2bba77..3183bf7c 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -1,11 +1,11 @@
 import { Flex, Item, TabList, TabPanels, Tabs, Text } from '@adobe/react-spectrum';
 import Beaker from '@spectrum-icons/workflow/Beaker';
-import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
+import Launch from '@spectrum-icons/workflow/Launch';
 import Extension from '@spectrum-icons/workflow/Extension';
-import FlashOn from '@spectrum-icons/workflow/FlashOn';
+import Calendar from '@spectrum-icons/workflow/Calendar';
 import Hand from '@spectrum-icons/workflow/Hand';
-import ScriptDisabledList from '../components/ScriptDisabledList';
-import ScriptEnabledList from '../components/ScriptEnabledList';
+import ScriptBootList from '../components/ScriptBootList.tsx';
+import ScriptScheduleList from '../components/ScriptScheduleList.tsx';
 import ScriptExtensionList from '../components/ScriptExtensionList';
 import ScriptManualList from '../components/ScriptManualList';
 import ScriptMockList from '../components/ScriptMockList';
@@ -25,13 +25,13 @@ const ScriptsPage = () => {
             <Hand />
             <Text>Manual</Text>
           </Item>
-          <Item aria-label="Automatic scripts" key="enabled">
-            <FlashOn />
-            <Text>Automatic</Text>
+          <Item aria-label="Boot scripts" key="boot">
+            <Launch />
+            <Text>Boot</Text>
           </Item>
-          <Item aria-label="Disabled scripts" key="disabled">
-            <CloseCircle />
-            <Text>Disabled</Text>
+          <Item aria-label="Schedule scripts" key="schedule">
+            <Calendar/>
+            <Text>Schedule</Text>
           </Item>
           {appState.mockStatus.enabled ? (
             <Item aria-label="Mock scripts" key="mock">
@@ -45,14 +45,14 @@ const ScriptsPage = () => {
           </Item>
         </TabList>
         <TabPanels flex="1" UNSAFE_style={{ display: 'flex' }}>
-          <Item key="manual" aria-label="Manual">
+          <Item key="manual" aria-label="Manual Script List">
             <ScriptManualList />
           </Item>
-          <Item key="enabled" aria-label="Enabled">
-            <ScriptEnabledList />
+          <Item key="boot" aria-label="Boot Script List">
+            <ScriptBootList />
           </Item>
-          <Item key="disabled" aria-label="Disabled">
-            <ScriptDisabledList />
+          <Item key="schedule" aria-label="Schedule Script List">
+            <ScriptScheduleList />
           </Item>
           {appState.mockStatus.enabled ? (
             <Item key="mock" aria-label="Mock">
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 37a0a142..991108f2 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -6,6 +6,7 @@ export type Executable = {
   arguments: ArgumentValues;
 };
 
+// TODO /conf/acm/settings/script => /apps/acm/script
 export const ScriptRootPath = '/conf/acm/settings/script';
 export const ConsoleDefaultScriptPath = `${ScriptRootPath}/template/core/console.groovy`;
 export const ConsoleDefaultScriptContent = `
@@ -287,9 +288,9 @@ export type Snippet = {
 };
 
 export enum ScriptType {
+  BOOT = 'BOOT',
+  SCHEDULE = 'SCHEDULE',
   MANUAL = 'MANUAL',
-  ENABLED = 'ENABLED',
-  DISABLED = 'DISABLED',
   EXTENSION = 'EXTENSION',
   MOCK = 'MOCK',
 }

From 12415fa4c35f1810fe08eacc323c7405d7bfb38a Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 31 Jul 2025 08:15:09 +0200
Subject: [PATCH 05/83] Script scheduler boot vs schedule distinction

---
 .../vml/es/acm/core/code/ExecutionQueue.java  |  2 +-
 .../es/acm/core/script/ScriptScheduler.java   | 27 ++++++++++++-------
 .../es/acm/core/servlet/QueueCodeServlet.java |  2 +-
 3 files changed, 20 insertions(+), 11 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 6cfad90e..39cc2df4 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -67,7 +67,7 @@ protected void deactivate() {
         }
     }
 
-    public Execution submit(ExecutionContextOptions contextOptions, Executable executable) throws AcmException {
+    public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
         jobProps.putAll(Code.toJobProps(executable));
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
index e8fd5699..29622500 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
@@ -8,6 +8,7 @@
 import dev.vml.es.acm.core.util.quartz.CronExpression;
 import java.text.ParseException;
 import java.util.Date;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -69,6 +70,8 @@ public class ScriptScheduler implements Runnable {
 
     private final AtomicLong runCount = new AtomicLong(0);
 
+    private final AtomicBoolean boot = new AtomicBoolean(true);
+
     @Activate
     @Modified
     protected void activate(Config config) {
@@ -105,20 +108,26 @@ public void run() {
         ExecutionContextOptions contextOptions = new ExecutionContextOptions(ExecutionMode.RUN, userId);
         try (ResourceResolver resourceResolver =
                 ResourceUtils.contentResolver(resourceResolverFactory, contextOptions.getUserId())) {
-            ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-
-            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> {
-                if (checkScript(script, resourceResolver)) {
-                    submitScript(script, contextOptions);
-                }
-            });
-
+            if (boot.compareAndSet(true, false)) {
+                submitScripts(ScriptType.BOOT, resourceResolver, contextOptions);
+            } else {
+                submitScripts(ScriptType.SCHEDULE, resourceResolver, contextOptions);
+            }
             runCount.incrementAndGet();
         } catch (Exception e) {
             LOG.error("Cannot access repository while scheduling enabled scripts to execution queue!", e);
         }
     }
 
+    private void submitScripts(ScriptType type, ResourceResolver resourceResolver, ExecutionContextOptions contextOptions) {
+        ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+        scriptRepository.findAll(type).forEach(script -> {
+            if (checkScript(script, resourceResolver)) {
+                submitScript(script, contextOptions);
+            }
+        });
+    }
+
     private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         try (ExecutionContext context =
                 executor.createContext(ExecutionId.generate(), ExecutionMode.CHECK, script, resourceResolver)) {
@@ -133,7 +142,7 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
 
     private void submitScript(Script script, ExecutionContextOptions contextOptions) {
         try {
-            queue.submit(contextOptions, script);
+            queue.submit(script, contextOptions);
         } catch (Exception e) {
             LOG.error("Cannot submit script '{}' to execution queue!", script.getId(), e);
         }
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index 3e29c58a..65d41052 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -75,7 +75,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
             ExecutionContextOptions contextOptions = new ExecutionContextOptions(
                     ExecutionMode.RUN, request.getResourceResolver().getUserID());
 
-            Execution execution = executionQueue.submit(contextOptions, code);
+            Execution execution = executionQueue.submit(code, contextOptions);
             QueueOutput output = new QueueOutput(Collections.singletonList(execution));
             respondJson(
                     response,

From 07b4d0a79880eeb0890bf81c0b56423a572b028a Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 31 Jul 2025 12:33:54 +0200
Subject: [PATCH 06/83] Script scheduler boot vs schedule distinction

---
 .../core/code/ExecutionContextOptions.java    |  2 +-
 .../vml/es/acm/core/code/ExecutionQueue.java  | 12 ++++++++
 .../es/acm/core/script/ScriptScheduler.java   | 29 +++++++++++++++----
 3 files changed, 37 insertions(+), 6 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
index 692d0d63..0ce632cd 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
@@ -7,7 +7,7 @@
 import org.apache.sling.event.jobs.Job;
 
 /**
- * Determines creation of execution context in asynchronous job.
+ * Determines creation of execution context in an asynchronous job.
  */
 public class ExecutionContextOptions implements Serializable {
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 39cc2df4..ed5b0b3f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -29,6 +29,8 @@
         property = {JobExecutor.PROPERTY_TOPICS + "=" + ExecutionQueue.TOPIC})
 public class ExecutionQueue implements JobExecutor {
 
+    public static final String NAME = "AEM Content Manager Execution Queue";
+
     public static final String TOPIC = "dev/vml/es/acm/ExecutionQueue";
 
     private static final Logger LOG = LoggerFactory.getLogger(ExecutionQueue.class);
@@ -194,4 +196,14 @@ private Execution executeAsync(ExecutionContextOptions contextOptions, QueuedExe
             throw new AcmException(String.format("Cannot access repository for execution '%s'", execution.getId()), e);
         }
     }
+
+    // TODO introduce a button using it
+    public void reset() {
+        if (jobAsyncExecutor != null && !jobAsyncExecutor.isShutdown()) {
+            jobAsyncExecutor.shutdownNow();
+        }
+        jobAsyncExecutor = Executors.newCachedThreadPool();
+        findJobs().forEach(job -> jobManager.removeJobById(job.getId()));
+        jobManager.getQueue(NAME).removeAll();
+    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
index 29622500..23b9255f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
@@ -8,8 +8,12 @@
 import dev.vml.es.acm.core.util.quartz.CronExpression;
 import java.text.ParseException;
 import java.util.Date;
+import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceResolverFactory;
@@ -102,16 +106,24 @@ public void run() {
             LOG.warn("Script scheduler is paused due to health status: {}", healthStatus);
             return;
         }
+        List<Execution> bootScripts = findQueuedBootScripts().collect(Collectors.toList());
+        if (!bootScripts.isEmpty()) {
+            LOG.info("Script scheduler is paused due to queued boot scripts ({}): {}", bootScripts.size(), StringUtils.join(bootScripts, ", "));
+            return;
+        }
+        queueScripts();
+    }
 
+    private void queueScripts() {
         String userId =
                 StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
         ExecutionContextOptions contextOptions = new ExecutionContextOptions(ExecutionMode.RUN, userId);
         try (ResourceResolver resourceResolver =
                 ResourceUtils.contentResolver(resourceResolverFactory, contextOptions.getUserId())) {
             if (boot.compareAndSet(true, false)) {
-                submitScripts(ScriptType.BOOT, resourceResolver, contextOptions);
+                queueScripts(ScriptType.BOOT, resourceResolver, contextOptions);
             } else {
-                submitScripts(ScriptType.SCHEDULE, resourceResolver, contextOptions);
+                queueScripts(ScriptType.SCHEDULE, resourceResolver, contextOptions);
             }
             runCount.incrementAndGet();
         } catch (Exception e) {
@@ -119,11 +131,11 @@ public void run() {
         }
     }
 
-    private void submitScripts(ScriptType type, ResourceResolver resourceResolver, ExecutionContextOptions contextOptions) {
+    private void queueScripts(ScriptType type, ResourceResolver resourceResolver, ExecutionContextOptions contextOptions) {
         ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
         scriptRepository.findAll(type).forEach(script -> {
             if (checkScript(script, resourceResolver)) {
-                submitScript(script, contextOptions);
+                queueScript(script, contextOptions);
             }
         });
     }
@@ -140,7 +152,7 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         }
     }
 
-    private void submitScript(Script script, ExecutionContextOptions contextOptions) {
+    private void queueScript(Script script, ExecutionContextOptions contextOptions) {
         try {
             queue.submit(script, contextOptions);
         } catch (Exception e) {
@@ -148,6 +160,13 @@ private void submitScript(Script script, ExecutionContextOptions contextOptions)
         }
     }
 
+    private Stream<Execution> findQueuedBootScripts() {
+        return queue.findAll().filter(execution -> {
+            ScriptType type = ScriptType.byPath(execution.getExecutable().getId()).orElse(null);
+            return ScriptType.BOOT.equals(type);
+        });
+    }
+
     public long getIntervalMillis() {
         return this.intervalMillis;
     }

From aca5fad6d66abf56a8d08db7b79b63d82ed903ad Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 31 Jul 2025 13:03:42 +0200
Subject: [PATCH 07/83] Script scheduler boot vs schedule distinction

---
 .../script/{schedule => boot}/example/ACME-20_once.groovy         | 0
 .../script/{boot => schedule}/example/ACME-21_every-hour.groovy   | 0
 .../script/{boot => schedule}/example/ACME-22_every-day.groovy    | 0
 .../script/{boot => schedule}/example/ACME-23_every-week.groovy   | 0
 .../script/{boot => schedule}/example/ACME-24_every-month.groovy  | 0
 5 files changed, 0 insertions(+), 0 deletions(-)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => boot}/example/ACME-20_once.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => schedule}/example/ACME-21_every-hour.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => schedule}/example/ACME-22_every-day.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => schedule}/example/ACME-23_every-week.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => schedule}/example/ACME-24_every-month.groovy (100%)

diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-20_once.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-20_once.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-21_every-hour.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-22_every-day.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-23_every-week.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-24_every-month.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy

From 10188e48168f760ef3258425ff36cfff2e9b94a2 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 31 Jul 2025 13:28:21 +0200
Subject: [PATCH 08/83] Optimized folders for more data

---
 .../dev/vml/es/acm/core/code/ExecutionHistory.java   | 12 +++---------
 .../main/java/dev/vml/es/acm/core/code/Locker.java   |  4 ++--
 .../dev/vml/es/acm/core/mock/MockRepository.java     |  3 ++-
 .../java/dev/vml/es/acm/core/repo/RepoResource.java  |  7 +++++--
 .../dev/vml/es/acm/core/script/ScriptRepository.java |  4 +++-
 .../vml/es/acm/core/snippet/SnippetRepository.java   |  3 ++-
 .../java/dev/vml/es/acm/core/util/ResourceUtils.java |  9 +++++----
 7 files changed, 22 insertions(+), 20 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
index b68f8591..7ed707e9 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
@@ -10,11 +10,10 @@
 import java.util.stream.Stream;
 import javax.jcr.query.Query;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.jackrabbit.JcrConstants;
 import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
-import org.apache.sling.api.resource.ResourceUtil;
-import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class ExecutionHistory {
 
@@ -36,12 +35,7 @@ public void save(ExecutionContext context, ImmediateExecution execution) throws
 
         try {
             String dirPath = root.getPath() + "/" + StringUtils.substringBeforeLast(execution.getId(), "/");
-            Resource dir = ResourceUtil.getOrCreateResource(
-                    resourceResolver,
-                    dirPath,
-                    JcrResourceConstants.NT_SLING_FOLDER,
-                    JcrResourceConstants.NT_SLING_FOLDER,
-                    true);
+            Resource dir = ResourceUtils.makeFolders(resourceResolver, dirPath, JcrConstants.NT_FOLDER);
             String entryName = StringUtils.substringAfterLast(execution.getId(), "/");
             resourceResolver.create(dir, entryName, props);
             resourceResolver.commit();
@@ -73,7 +67,7 @@ private Optional<Resource> readResource(String id) {
     }
 
     private Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, ROOT);
+        return ResourceUtils.makeFolders(resourceResolver, ROOT, JcrConstants.NT_FOLDER);
     }
 
     public boolean contains(String id) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
index eaaed373..2726d8aa 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
@@ -51,9 +51,9 @@ public void lock(String lockName) {
             if (name.contains("/")) {
                 String dirPath = StringUtils.substringBeforeLast(name, "/");
                 nodeName = StringUtils.substringAfterLast(name, "/");
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT + "/" + dirPath);
+                dirResource = ResourceUtils.makeFolders(resolver, ROOT + "/" + dirPath, JcrConstants.NT_FOLDER);
             } else {
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT);
+                dirResource = ResourceUtils.makeFolders(resolver, ROOT, JcrConstants.NT_FOLDER);
                 nodeName = name;
             }
             Map<String, Object> props = new HashMap<>();
diff --git a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
index 2921b874..c0b0bbab 100644
--- a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
@@ -11,6 +11,7 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.jackrabbit.JcrConstants;
 import org.apache.sling.api.resource.*;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class MockRepository {
 
@@ -29,7 +30,7 @@ public MockRepository(ResourceResolver resolver) {
     }
 
     public Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resolver, ScriptType.MOCK.root());
+        return ResourceUtils.makeFolders(resolver, ScriptType.MOCK.root(), JcrResourceConstants.NT_SLING_FOLDER);
     }
 
     public boolean checkResource(Resource resource) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
index 8fc2a8d3..a243e4dd 100644
--- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
+++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
@@ -83,6 +83,10 @@ public boolean exists() {
     }
 
     public RepoResource ensureFolder() {
+        return ensure(JcrConstants.NT_FOLDER);
+    }
+
+    public RepoResource ensureRegularFolder() {
         return ensure(JcrResourceConstants.NT_SLING_FOLDER);
     }
 
@@ -94,8 +98,7 @@ public RepoResource ensure(String resourceType) {
         Resource resource = resolve();
         if (resource == null) {
             try {
-                ResourceUtil.getOrCreateResource(
-                        repo.getResourceResolver(), path, resourceType, resourceType, repo.isAutoCommit());
+                ResourceUtil.getOrCreateResource(repo.getResourceResolver(), path, Collections.emptyMap(), resourceType, repo.isAutoCommit());
             } catch (PersistenceException e) {
                 throw new RepoException(
                         String.format("Cannot ensure resource '%s' at path '%s'!", resourceType, path), e);
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index 3cf03bed..e3c05e6e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -7,8 +7,10 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
+
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class ScriptRepository {
 
@@ -45,6 +47,6 @@ public Stream<Script> readAll(List<String> ids) {
     }
 
     private Resource getOrCreateRoot(ScriptType type) throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, type.root());
+        return ResourceUtils.makeFolders(resourceResolver, type.root(), JcrResourceConstants.NT_SLING_FOLDER);
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java b/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
index a177083d..e711da78 100644
--- a/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
@@ -10,6 +10,7 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class SnippetRepository {
 
@@ -47,6 +48,6 @@ public Stream<Snippet> findAll() throws AcmException {
     }
 
     private Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, ROOT);
+        return ResourceUtils.makeFolders(resourceResolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index f37a2aab..ea82771b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -1,6 +1,8 @@
 package dev.vml.es.acm.core.util;
 
 import dev.vml.es.acm.core.AcmException;
+
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -8,7 +10,6 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.*;
 import org.apache.sling.api.resource.LoginException;
-import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public final class ResourceUtils {
 
@@ -105,13 +106,13 @@ public static void move(ResourceResolver resolver, String sourcePath, String tar
         }
     }
 
-    public static Resource makeFolders(ResourceResolver resourceResolver, String path) throws AcmException {
+    public static Resource makeFolders(ResourceResolver resourceResolver, String path, String resourceType) throws AcmException {
         try {
             return ResourceUtil.getOrCreateResource(
                     resourceResolver,
                     path,
-                    JcrResourceConstants.NT_SLING_FOLDER,
-                    JcrResourceConstants.NT_SLING_FOLDER,
+                    Collections.emptyMap(),
+                    resourceType,
                     true);
         } catch (Exception e) {
             throw new AcmException(String.format("Folders cannot be created for path '%s'", path), e);

From dcbf804f57d69c034dd1737600e101db1481c7cb Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 31 Jul 2025 16:22:57 +0200
Subject: [PATCH 09/83] Safe script execution

---
 README.md                                     |  2 +-
 .../dev/vml/es/acm/core/code/Conditions.java  | 24 +++----------------
 .../vml/es/acm/core/code/ExecutionQueue.java  |  2 ++
 .../script/boot/example/ACME-20_once.groovy   |  2 +-
 .../example/ACME-21_every-hour.groovy         |  2 +-
 .../schedule/example/ACME-22_every-day.groovy |  2 +-
 .../example/ACME-23_every-week.groovy         |  2 +-
 .../example/ACME-24_every-month.groovy        |  2 +-
 .../snippet/available/core/acl/demo.yml       |  2 +-
 .../available/core/condition/not_queued.yml   |  6 -----
 .../core/condition/not_queued_self.yml        |  6 -----
 .../condition/retry_if_instance_changed.yml   |  4 +---
 .../core/general/demo_processing.yml          |  2 +-
 .../snippet/available/core/repo/demo.yml      |  2 +-
 14 files changed, 15 insertions(+), 45 deletions(-)
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued_self.yml

diff --git a/README.md b/README.md
index 8654d36c..06bbdb44 100644
--- a/README.md
+++ b/README.md
@@ -250,7 +250,7 @@ The following example demonstrates how to create a user and a group, assign perm
 
 ```groovy
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.everyHour()
+    return conditions.everyHour()
 }
 
 void doRun() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
index 1bd36118..aa432467 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
@@ -61,26 +61,8 @@ public Stream<Execution> passedExecutions() {
         return executionHistory.findAll(query);
     }
 
-    public boolean idle() {
-        return notQueued();
-    }
-
-    public boolean idleSelf() {
-        return notQueuedSelf();
-    }
-
-    public boolean notQueued() {
-        if (executionContext.getExecutor().isLocking() && lockedAny()) {
-            return false;
-        }
-        return !queuedExecutions().findAny().isPresent();
-    }
-
-    public boolean notQueuedSelf() {
-        if (executionContext.getExecutor().isLocking() && lockedSelf()) {
-            return false;
-        }
-        return !queuedSelfExecutions().findAny().isPresent();
+    public boolean notSucceeded() {
+        return false; // TODO implement this
     }
 
     public boolean notRunning() {
@@ -635,7 +617,7 @@ public boolean retry(long count) {
         if (count < 1) {
             throw new IllegalArgumentException("Retry count must be greater than zero!");
         }
-        if (!notQueuedSelf()) {
+        if (!notRunningSelf()) {
             return false;
         }
         Execution passedExecution = passedExecution();
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index ed5b0b3f..0d9ad6f9 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -69,6 +69,8 @@ protected void deactivate() {
         }
     }
 
+    // TODO do prechecks before adding to queue
+    // [1] check if not queued already, [2] check if canRun
     public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
index 5f76b037..aaefb5b9 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
@@ -1,5 +1,5 @@
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.once()
+    return conditions.once()
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
index 14274e3d..8466e38f 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
@@ -1,5 +1,5 @@
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.everyHourAt(43)
+    return conditions.everyHourAt(43)
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
index b49192a5..8ff235af 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
@@ -1,5 +1,5 @@
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.everyDayAt("07:45")
+    return conditions.everyDayAt("07:45")
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
index 2feec899..94b5cdab 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
@@ -1,5 +1,5 @@
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.everyWeekAt("Monday", "07:48")
+    return conditions.everyWeekAt("Monday", "07:48")
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy
index 30f075ab..99243909 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy
@@ -1,5 +1,5 @@
 boolean canRun() {
-    return conditions.notQueuedSelf() && conditions.everyMonthAt(21, "07:49")
+    return conditions.everyMonthAt(21, "07:49")
 }
 
 void doRun() {
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/acl/demo.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/acl/demo.yml
index bc969256..3bd76abf 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/acl/demo.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/acl/demo.yml
@@ -4,7 +4,7 @@ documentation: |
   Setup the ACLs for the demo users and groups.
 content: |
   boolean canRun() {
-    return conditions.notQueuedSelf()
+    return conditions.always()
   }
     
   void doRun() {
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued.yml
deleted file mode 100644
index cca10680..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-group: Condition
-name: condition_not_queued
-content: |
-  conditions.notQueued()
-documentation: |
-  Similar to `conditions.notRunning()` but also checks if none of the scripts are queued for execution.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued_self.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued_self.yml
deleted file mode 100644
index 687beecb..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_queued_self.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-group: Condition
-name: condition_not_queued_self
-content: |
-  conditions.notQueuedSelf()
-documentation: |
-  Similar to `conditions.notRunningSelf()` but also checks if the script is not even queued for execution.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/retry_if_instance_changed.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/retry_if_instance_changed.yml
index bb094ef1..38f8a435 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/retry_if_instance_changed.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/retry_if_instance_changed.yml
@@ -5,12 +5,10 @@ content: |
 documentation: |
   Execute the script only if the previous execution failed and the instance has changed due to a deployment, such as an instance restart or an OSGi bundle change.
   
-  This condition combined with `contentChanged()` is especially useful for content upgrade scripts.
-  
   For example:
   ```groovy
   boolean canRun() {
-    return conditions.notQueuedSelf() && (conditions.contentChanged() || conditions.retryIfInstanceChanged())
+    return conditions.retryIfInstanceChanged()
   }
   
   void doRun() {
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
index af3f69f0..c258afea 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
@@ -2,7 +2,7 @@ group: General
 name: general_demo_processing
 content: |
   boolean canRun() {
-    return conditions.notQueuedSelf()
+    return conditions.always()
   }
     
   void doRun() {
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/demo.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/demo.yml
index b04bce00..9d2ffc5c 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/demo.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/demo.yml
@@ -16,7 +16,7 @@ content: |
   }
     
   boolean canRun() {
-    return conditions.notQueuedSelf()
+    return conditions.always()
   }
     
   void doRun() {

From df2ab77eba8cfc1d1ddf7c6ef80cffa566554e4d Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Fri, 1 Aug 2025 16:05:33 +0200
Subject: [PATCH 10/83] Scheduler / quartz

---
 .../vml/es/acm/core/code/ExecutionQueue.java  |  2 +-
 .../core/script/AutomaticScriptScheduler.java | 93 +++++++++++++++++++
 .../es/acm/core/script/ScriptSchedule.java    |  6 ++
 .../core/script/schedule/BootSchedule.java    | 11 +++
 .../core/script/schedule/CronSchedule.java    | 21 +++++
 .../{schedule => automatic}/.content.xml      |  0
 .../example/.content.xml                      |  0
 .../example/ACME-100_acl.groovy               |  0
 .../example/ACME-20_once.groovy               |  4 +
 .../example/ACME-21_every-hour.groovy         | 13 +++
 .../example/ACME-22_every-day.groovy          |  0
 .../example/ACME-23_every-week.groovy         |  0
 .../example/ACME-24_every-month.groovy        |  0
 .../example/ACME-21_every-hour.groovy         |  7 --
 14 files changed, 149 insertions(+), 8 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => automatic}/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => automatic}/example/.content.xml (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => automatic}/example/ACME-100_acl.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{boot => automatic}/example/ACME-20_once.groovy (67%)
 create mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => automatic}/example/ACME-22_every-day.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => automatic}/example/ACME-23_every-week.groovy (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/{schedule => automatic}/example/ACME-24_every-month.groovy (100%)
 delete mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 0d9ad6f9..b6776f0f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -70,7 +70,7 @@ protected void deactivate() {
     }
 
     // TODO do prechecks before adding to queue
-    // [1] check if not queued already, [2] check if canRun
+    // [1] check if not queued already, [2] check if canRun, [3] check if not locked
     public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
new file mode 100644
index 00000000..63f8948d
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -0,0 +1,93 @@
+package dev.vml.es.acm.core.script;
+
+import dev.vml.es.acm.core.script.schedule.BootSchedule;
+import dev.vml.es.acm.core.script.schedule.CronSchedule;
+import dev.vml.es.acm.core.util.ResourceUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.jetbrains.annotations.NotNull;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component(
+        service = ResourceChangeListener.class,
+        immediate = true,
+        property = {
+                ResourceChangeListener.PATHS + "=glob:"+ScriptRepository.ROOT+"/automatic/**/*.groovy",
+                ResourceChangeListener.CHANGES + "=ADDED",
+                ResourceChangeListener.CHANGES + "=CHANGED",
+                ResourceChangeListener.CHANGES + "=REMOVED"
+        }
+)
+public class AutomaticScriptScheduler implements ResourceChangeListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
+
+    private final Map<String, ScriptSchedule> schedules = new ConcurrentHashMap<>();
+
+    @Reference
+    private ResourceResolverFactory resourceResolverFactory;
+
+    @Reference
+    private Scheduler scheduler;
+
+    @Override
+    public void onChange(@NotNull List<ResourceChange> changes) {
+        if (changes.isEmpty()) {
+            return;
+        }
+        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            for (ResourceChange change : changes) {
+                String scriptPath = change.getPath();
+                unschedule(scriptPath);
+                switch (change.getType()) {
+                    case ADDED:
+                    case CHANGED:
+                        schedule(scriptPath, resourceResolver);
+                        break;
+                }
+            }
+        } catch (LoginException e) {
+            LOG.error("Cannot access repository while processing automatic script changes!", e);
+        }
+    }
+
+    private void unschedule(String scriptPath) {
+        ScriptSchedule schedule = schedules.get(scriptPath);
+        if (schedule != null) {
+            scheduler.unschedule(scriptPath);
+        }
+    }
+
+    private void schedule(String scriptPath, ResourceResolver resourceResolver) {
+        ScriptSchedule schedule = determineSchedule(scriptPath, resourceResolver);
+        if (schedule instanceof BootSchedule) {
+            schedules.put(scriptPath, schedule);
+        } else if (schedule instanceof CronSchedule) {
+            CronSchedule cron = (CronSchedule) schedule;
+            if (StringUtils.isNotBlank(cron.getExpression())) {
+                scheduler.schedule(scriptPath, scheduler.EXPR(cron.getExpression()));
+                schedules.put(scriptPath, schedule);
+            } else {
+                LOG.error("No cron expression provided for script: {}", scriptPath);
+            }
+        } else {
+            LOG.error("Unsupported schedule type for script: {}", scriptPath);
+        }
+    }
+
+    private ScriptSchedule determineSchedule(String scriptPath, ResourceResolver resourceResolver) {
+        return new BootSchedule(); // call script's scheduleRun() method
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
new file mode 100644
index 00000000..29b81903
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
@@ -0,0 +1,6 @@
+package dev.vml.es.acm.core.script;
+
+public interface ScriptSchedule {
+
+    String getId();
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
new file mode 100644
index 00000000..98b52716
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
@@ -0,0 +1,11 @@
+package dev.vml.es.acm.core.script.schedule;
+
+import dev.vml.es.acm.core.script.ScriptSchedule;
+
+public class BootSchedule implements ScriptSchedule {
+
+    @Override
+    public String getId() {
+        return "boot";
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java
new file mode 100644
index 00000000..19663308
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java
@@ -0,0 +1,21 @@
+package dev.vml.es.acm.core.script.schedule;
+
+import dev.vml.es.acm.core.script.ScriptSchedule;
+
+public class CronSchedule implements ScriptSchedule {
+
+    private final String expression;
+
+    public CronSchedule(String expression) {
+       this.expression = expression;
+    }
+
+    @Override
+    public String getId() {
+        return "cron";
+    }
+
+    public String getExpression() {
+        return expression;
+    }
+}
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/.content.xml b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/.content.xml
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/.content.xml
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/.content.xml
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-100_acl.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-100_acl.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
similarity index 67%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
index aaefb5b9..42c47d8d 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/boot/example/ACME-20_once.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
@@ -1,3 +1,7 @@
+def scheduleRun() {
+    return schedules.boot()
+}
+
 boolean canRun() {
     return conditions.once()
 }
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
new file mode 100644
index 00000000..6c7aa881
--- /dev/null
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
@@ -0,0 +1,13 @@
+def scheduleRun() {
+    return schedules.cron("0 0 * ? * * *")
+}
+
+boolean canRun() {
+    return conditions.always()
+}
+
+void doRun() {
+    println("I should run every hour!")
+}
+
+
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-22_every-day.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-23_every-week.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-24_every-month.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
deleted file mode 100644
index 8466e38f..00000000
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/schedule/example/ACME-21_every-hour.groovy
+++ /dev/null
@@ -1,7 +0,0 @@
-boolean canRun() {
-    return conditions.everyHourAt(43)
-}
-
-void doRun() {
-    println("I should run every hour!")
-}

From f3f32f4201c84dabed18a7271d819b48284f9689 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 4 Aug 2025 08:15:01 +0200
Subject: [PATCH 11/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java | 53 +++++++++++++++++--
 1 file changed, 49 insertions(+), 4 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 63f8948d..a07ca610 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -9,9 +9,12 @@
 import org.apache.sling.api.resource.ResourceResolverFactory;
 import org.apache.sling.api.resource.observation.ResourceChange;
 import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.jetbrains.annotations.NotNull;
+import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Modified;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -42,6 +45,32 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     @Reference
     private Scheduler scheduler;
 
+    @Activate
+    @Modified
+    protected void activate() {
+        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> schedule(script.getPath(), resourceResolver));
+            ScheduleOptions options = configureSchedule("boot", scheduler.NOW());
+            scheduler.schedule(new Runnable() {
+                @Override
+                public void run() {
+                    // TODO ... loop over all schedules that schedule is 'boot'
+                }
+            }, options);
+        } catch (LoginException e) {
+            LOG.error("Cannot access repository while starting automatic script scheduler!", e);
+        }
+    }
+
+    private ScheduleOptions configureSchedule(String name, ScheduleOptions options) {
+        options.name(name);
+        options.onLeaderOnly(true);
+        options.canRunConcurrently(false);
+        options.onSingleInstanceOnly(true);
+        return options;
+    }
+
     @Override
     public void onChange(@NotNull List<ResourceChange> changes) {
         if (changes.isEmpty()) {
@@ -55,6 +84,7 @@ public void onChange(@NotNull List<ResourceChange> changes) {
                     case ADDED:
                     case CHANGED:
                         schedule(scriptPath, resourceResolver);
+                        // TODO is non-cron schedule is here then do another boot schedule?
                         break;
                 }
             }
@@ -65,8 +95,16 @@ public void onChange(@NotNull List<ResourceChange> changes) {
 
     private void unschedule(String scriptPath) {
         ScriptSchedule schedule = schedules.get(scriptPath);
-        if (schedule != null) {
+        if (schedule == null) {
+            return;
+        }
+        if (schedule instanceof BootSchedule) {
+            schedules.remove(scriptPath);
+        } else if (schedule instanceof CronSchedule) {
             scheduler.unschedule(scriptPath);
+            schedules.remove(scriptPath);
+        } else {
+            throw new IllegalStateException(String.format("Schedule '%s' for script '%s' is not supported!", schedule.getId(), scriptPath));
         }
     }
 
@@ -77,13 +115,20 @@ private void schedule(String scriptPath, ResourceResolver resourceResolver) {
         } else if (schedule instanceof CronSchedule) {
             CronSchedule cron = (CronSchedule) schedule;
             if (StringUtils.isNotBlank(cron.getExpression())) {
-                scheduler.schedule(scriptPath, scheduler.EXPR(cron.getExpression()));
+                ScheduleOptions options = configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression()));
+                configureSchedule(scriptPath, options);
+                scheduler.schedule(new Runnable() {
+                    @Override
+                    public void run() {
+                        // TODO add script to the queue
+                    }
+                }, options);
                 schedules.put(scriptPath, schedule);
             } else {
-                LOG.error("No cron expression provided for script: {}", scriptPath);
+                LOG.error("Schedule '{}' for script '{}' has no cron expression defined!", schedule.getId(), scriptPath);
             }
         } else {
-            LOG.error("Unsupported schedule type for script: {}", scriptPath);
+            throw new IllegalStateException(String.format("Schedule '%s' for script '%s' is not supported!", schedule.getId(), scriptPath));
         }
     }
 

From 9e773392b89eeefad6c4fc8485c472e9c3e7be18 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 4 Aug 2025 08:39:03 +0200
Subject: [PATCH 12/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java | 59 ++++++++++++++-----
 1 file changed, 44 insertions(+), 15 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index a07ca610..b6437c49 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -1,5 +1,8 @@
 package dev.vml.es.acm.core.script;
 
+import dev.vml.es.acm.core.code.ExecutionQueue;
+import dev.vml.es.acm.core.instance.HealthChecker;
+import dev.vml.es.acm.core.instance.HealthStatus;
 import dev.vml.es.acm.core.script.schedule.BootSchedule;
 import dev.vml.es.acm.core.script.schedule.CronSchedule;
 import dev.vml.es.acm.core.util.ResourceUtils;
@@ -9,6 +12,7 @@
 import org.apache.sling.api.resource.ResourceResolverFactory;
 import org.apache.sling.api.resource.observation.ResourceChange;
 import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.commons.scheduler.Job;
 import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.jetbrains.annotations.NotNull;
@@ -45,19 +49,19 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     @Reference
     private Scheduler scheduler;
 
+    @Reference
+    private HealthChecker healthChecker;
+
+    @Reference
+    private ExecutionQueue executionQueue;
+
     @Activate
     @Modified
     protected void activate() {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
             scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> schedule(script.getPath(), resourceResolver));
-            ScheduleOptions options = configureSchedule("boot", scheduler.NOW());
-            scheduler.schedule(new Runnable() {
-                @Override
-                public void run() {
-                    // TODO ... loop over all schedules that schedule is 'boot'
-                }
-            }, options);
+            scheduler.schedule(bootJob(), configureSchedule("boot", scheduler.NOW()));
         } catch (LoginException e) {
             LOG.error("Cannot access repository while starting automatic script scheduler!", e);
         }
@@ -115,14 +119,7 @@ private void schedule(String scriptPath, ResourceResolver resourceResolver) {
         } else if (schedule instanceof CronSchedule) {
             CronSchedule cron = (CronSchedule) schedule;
             if (StringUtils.isNotBlank(cron.getExpression())) {
-                ScheduleOptions options = configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression()));
-                configureSchedule(scriptPath, options);
-                scheduler.schedule(new Runnable() {
-                    @Override
-                    public void run() {
-                        // TODO add script to the queue
-                    }
-                }, options);
+                scheduler.schedule(cronJob(), configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression())));
                 schedules.put(scriptPath, schedule);
             } else {
                 LOG.error("Schedule '{}' for script '{}' has no cron expression defined!", schedule.getId(), scriptPath);
@@ -135,4 +132,36 @@ public void run() {
     private ScriptSchedule determineSchedule(String scriptPath, ResourceResolver resourceResolver) {
         return new BootSchedule(); // call script's scheduleRun() method
     }
+
+    private Job bootJob() {
+        return context -> {
+            HealthStatus healthStatus = null;
+            while (healthStatus == null || !healthStatus.isHealthy()) {
+                healthStatus = healthChecker.checkStatus();
+                if (healthStatus.isHealthy()) {
+                    break;
+                } else {
+                    LOG.warn("Boot script scheduler is paused due to health status: {}", healthStatus);
+                    try {
+                        Thread.sleep(1000); // wait before retrying
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        LOG.error("Boot script scheduler interrupted!", e);
+                    }
+                }
+            }
+            LOG.info("Boot script scheduler is starting...");
+            for (Map.Entry<String, ScriptSchedule> entry : schedules.entrySet()) { // TODO do not boot twice same scripts
+                if (entry.getValue() instanceof BootSchedule) {
+                    // TODO executionQueue.submit()
+                }
+            }
+        };
+    }
+
+    private Job cronJob() {
+        return context -> {
+            // TODO ...
+        };
+    }
 }

From c5abaf176867a42f74d753717aedb21c87aa4f5e Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 4 Aug 2025 10:16:29 +0200
Subject: [PATCH 13/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java | 87 +++++++++++++++----
 .../core/script/schedule/BootSchedule.java    |  8 ++
 2 files changed, 77 insertions(+), 18 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index b6437c49..be630c9b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -1,5 +1,7 @@
 package dev.vml.es.acm.core.script;
 
+import dev.vml.es.acm.core.code.ExecutionContextOptions;
+import dev.vml.es.acm.core.code.ExecutionMode;
 import dev.vml.es.acm.core.code.ExecutionQueue;
 import dev.vml.es.acm.core.instance.HealthChecker;
 import dev.vml.es.acm.core.instance.HealthStatus;
@@ -15,7 +17,6 @@
 import org.apache.sling.commons.scheduler.Job;
 import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Modified;
@@ -41,6 +42,8 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
 
+    private static final String BOOT_JOB_NAME = "boot";
+
     private final Map<String, ScriptSchedule> schedules = new ConcurrentHashMap<>();
 
     @Reference
@@ -60,8 +63,8 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     protected void activate() {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> schedule(script.getPath(), resourceResolver));
-            scheduler.schedule(bootJob(), configureSchedule("boot", scheduler.NOW()));
+            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> scheduleScript(script.getPath(), resourceResolver));
+            scheduler.schedule(bootJob(), configureSchedule(BOOT_JOB_NAME, scheduler.NOW()));
         } catch (LoginException e) {
             LOG.error("Cannot access repository while starting automatic script scheduler!", e);
         }
@@ -76,28 +79,38 @@ private ScheduleOptions configureSchedule(String name, ScheduleOptions options)
     }
 
     @Override
-    public void onChange(@NotNull List<ResourceChange> changes) {
+    public void onChange(List<ResourceChange> changes) {
         if (changes.isEmpty()) {
             return;
         }
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            boolean reboot = false;
             for (ResourceChange change : changes) {
                 String scriptPath = change.getPath();
-                unschedule(scriptPath);
+
+                unscheduleScript(scriptPath);
+
                 switch (change.getType()) {
                     case ADDED:
                     case CHANGED:
-                        schedule(scriptPath, resourceResolver);
-                        // TODO is non-cron schedule is here then do another boot schedule?
+                        scheduleScript(scriptPath, resourceResolver);
+                        reboot = true;
                         break;
                 }
             }
+            if (reboot) {
+                LOG.info("Boot scripts changed, reboot required");
+                scheduler.unschedule(BOOT_JOB_NAME);
+                scheduler.schedule(bootJob(), configureSchedule(BOOT_JOB_NAME, scheduler.NOW()));
+            } else {
+                LOG.info("Boot scripts not changed, reboot skipped");
+            }
         } catch (LoginException e) {
             LOG.error("Cannot access repository while processing automatic script changes!", e);
         }
     }
 
-    private void unschedule(String scriptPath) {
+    private void unscheduleScript(String scriptPath) {
         ScriptSchedule schedule = schedules.get(scriptPath);
         if (schedule == null) {
             return;
@@ -112,14 +125,14 @@ private void unschedule(String scriptPath) {
         }
     }
 
-    private void schedule(String scriptPath, ResourceResolver resourceResolver) {
+    private void scheduleScript(String scriptPath, ResourceResolver resourceResolver) {
         ScriptSchedule schedule = determineSchedule(scriptPath, resourceResolver);
         if (schedule instanceof BootSchedule) {
             schedules.put(scriptPath, schedule);
         } else if (schedule instanceof CronSchedule) {
             CronSchedule cron = (CronSchedule) schedule;
             if (StringUtils.isNotBlank(cron.getExpression())) {
-                scheduler.schedule(cronJob(), configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression())));
+                scheduler.schedule(cronJob(scriptPath), configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression())));
                 schedules.put(scriptPath, schedule);
             } else {
                 LOG.error("Schedule '{}' for script '{}' has no cron expression defined!", schedule.getId(), scriptPath);
@@ -141,27 +154,65 @@ private Job bootJob() {
                 if (healthStatus.isHealthy()) {
                     break;
                 } else {
-                    LOG.warn("Boot script scheduler is paused due to health status: {}", healthStatus);
+                    LOG.warn("Boot scripts paused due to health status: {}", healthStatus);
                     try {
                         Thread.sleep(1000); // wait before retrying
                     } catch (InterruptedException e) {
                         Thread.currentThread().interrupt();
-                        LOG.error("Boot script scheduler interrupted!", e);
+                        LOG.error("Boot scripts queueing interrupted!", e);
                     }
                 }
             }
-            LOG.info("Boot script scheduler is starting...");
-            for (Map.Entry<String, ScriptSchedule> entry : schedules.entrySet()) { // TODO do not boot twice same scripts
-                if (entry.getValue() instanceof BootSchedule) {
-                    // TODO executionQueue.submit()
+            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+                ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+                for (Map.Entry<String, ScriptSchedule> e : schedules.entrySet()) {
+                    if (e.getValue() instanceof BootSchedule) {
+                        String scriptPath = e.getKey();
+                        BootSchedule boot = (BootSchedule) e.getValue();
+                        Script script = scriptRepository.read(scriptPath).orElse(null);
+                        if (script == null) {
+                            LOG.error("Boot script '{}' cannot be read while queueing!", scriptPath);
+                        } else if (!boot.getDone().get()) {
+                            if (!checkScript(script)) {
+                                LOG.info("Boot script '{}' is not ready for queueing!", scriptPath);
+                            } else {
+                                queueScript(script);
+                                boot.getDone().set(true);
+                            }
+                        }
+                    }
                 }
+            } catch (LoginException e) {
+                LOG.error("Cannot access repository while boot scripts queueing!", e);
             }
         };
     }
 
-    private Job cronJob() {
+    private boolean checkScript(Script script) {
+        return false;
+    }
+
+    private void queueScript(Script script) {
+        executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, null));
+    }
+
+    private Job cronJob(String scriptPath) {
         return context -> {
-            // TODO ...
+            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+                ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+                Script script = scriptRepository.read(scriptPath).orElse(null);
+                if (script == null) {
+                    LOG.warn("Script '{}' not found in repository, not queueing!", scriptPath);
+                } else {
+                    if (checkScript(script)) {
+                        queueScript(script);
+                    } else {
+                        LOG.debug("Script '{}' is not ready for execution, not queueing!", scriptPath);
+                    }
+                }
+            } catch (LoginException e) {
+                LOG.error("Cannot access repository while queueing cron script execution '{}'!", scriptPath, e);
+            }
         };
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
index 98b52716..7a4dfe49 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
@@ -2,10 +2,18 @@
 
 import dev.vml.es.acm.core.script.ScriptSchedule;
 
+import java.util.concurrent.atomic.AtomicBoolean;
+
 public class BootSchedule implements ScriptSchedule {
 
+    private final AtomicBoolean done = new AtomicBoolean(false);
+
     @Override
     public String getId() {
         return "boot";
     }
+
+    public AtomicBoolean getDone() {
+        return done;
+    }
 }

From 95a02536954423c68fbbe4bd3daadcb54d3529e7 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 4 Aug 2025 12:01:50 +0200
Subject: [PATCH 14/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java | 212 +++++++++---------
 .../vml/es/acm/core/script/ScriptType.java    |   2 +-
 .../core/script/schedule/BootSchedule.java    |   8 -
 3 files changed, 108 insertions(+), 114 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index be630c9b..93c3ee7e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -5,6 +5,7 @@
 import dev.vml.es.acm.core.code.ExecutionQueue;
 import dev.vml.es.acm.core.instance.HealthChecker;
 import dev.vml.es.acm.core.instance.HealthStatus;
+import dev.vml.es.acm.core.repo.Repo;
 import dev.vml.es.acm.core.script.schedule.BootSchedule;
 import dev.vml.es.acm.core.script.schedule.CronSchedule;
 import dev.vml.es.acm.core.util.ResourceUtils;
@@ -19,7 +20,6 @@
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Modified;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -27,6 +27,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 @Component(
         service = ResourceChangeListener.class,
@@ -44,7 +46,11 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final String BOOT_JOB_NAME = "boot";
 
-    private final Map<String, ScriptSchedule> schedules = new ConcurrentHashMap<>();
+    private boolean instanceReady = false;
+
+    private final Map<String, AtomicBoolean> booted = new ConcurrentHashMap<>();
+
+    private final List<String> scheduled = new CopyOnWriteArrayList<>();
 
     @Reference
     private ResourceResolverFactory resourceResolverFactory;
@@ -59,18 +65,45 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     private ExecutionQueue executionQueue;
 
     @Activate
-    @Modified
     protected void activate() {
+        checkInstanceReady();
+        bootWhenInstanceUp();
+    }
+
+    private void checkInstanceReady() {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-            ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-            scriptRepository.findAll(ScriptType.SCHEDULE).forEach(script -> scheduleScript(script.getPath(), resourceResolver));
-            scheduler.schedule(bootJob(), configureSchedule(BOOT_JOB_NAME, scheduler.NOW()));
+            Repo repo = new Repo(resourceResolver);
+            this.instanceReady = repo.isCompositeNodeStore();
         } catch (LoginException e) {
-            LOG.error("Cannot access repository while starting automatic script scheduler!", e);
+            LOG.error("Cannot access repository while processing automatic script changes!", e);
+            this.instanceReady = false;
+        }
+    }
+
+    @Override
+    public void onChange(List<ResourceChange> changes) {
+        if (!instanceReady || changes.isEmpty()) {
+            return;
         }
+        bootWhenScriptsChanged();
+    }
+
+    private void bootWhenInstanceUp() {
+        LOG.info("Automatic scripts booting on instance up");
+        scheduleBoot();
+    }
+
+    private void bootWhenScriptsChanged() {
+        LOG.info("Automatic scripts booting on script changes");
+        scheduleBoot();
     }
 
-    private ScheduleOptions configureSchedule(String name, ScheduleOptions options) {
+    private void scheduleBoot() {
+        scheduler.unschedule(BOOT_JOB_NAME);
+        scheduler.schedule(bootJob(), configureScheduleOptions(BOOT_JOB_NAME, scheduler.NOW()));
+    }
+
+    private ScheduleOptions configureScheduleOptions(String name, ScheduleOptions options) {
         options.name(name);
         options.onLeaderOnly(true);
         options.canRunConcurrently(false);
@@ -78,114 +111,83 @@ private ScheduleOptions configureSchedule(String name, ScheduleOptions options)
         return options;
     }
 
-    @Override
-    public void onChange(List<ResourceChange> changes) {
-        if (changes.isEmpty()) {
-            return;
-        }
-        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-            boolean reboot = false;
-            for (ResourceChange change : changes) {
-                String scriptPath = change.getPath();
-
-                unscheduleScript(scriptPath);
-
-                switch (change.getType()) {
-                    case ADDED:
-                    case CHANGED:
-                        scheduleScript(scriptPath, resourceResolver);
-                        reboot = true;
-                        break;
-                }
-            }
-            if (reboot) {
-                LOG.info("Boot scripts changed, reboot required");
-                scheduler.unschedule(BOOT_JOB_NAME);
-                scheduler.schedule(bootJob(), configureSchedule(BOOT_JOB_NAME, scheduler.NOW()));
+    private ScriptSchedule determineSchedule(String scriptPath, ResourceResolver resourceResolver) {
+        return new BootSchedule(); // TODO call script's scheduleRun() method
+    }
+
+    private Job bootJob() {
+        return context -> {
+            unscheduleScripts();
+            awaitInstanceHealthy();
+            bootOrScheduleScripts();
+        };
+    }
+
+    private void awaitInstanceHealthy() {
+        HealthStatus healthStatus = null;
+        while (healthStatus == null || !healthStatus.isHealthy()) {
+            healthStatus = healthChecker.checkStatus();
+            if (healthStatus.isHealthy()) {
+                break;
             } else {
-                LOG.info("Boot scripts not changed, reboot skipped");
+                LOG.warn("Automatic scripts booting paused due to health status: {}", healthStatus);
+                try {
+                    Thread.sleep(1000); // wait before retrying
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    LOG.error("Automatic scripts booting interrupted!", e);
+                }
             }
-        } catch (LoginException e) {
-            LOG.error("Cannot access repository while processing automatic script changes!", e);
         }
     }
 
-    private void unscheduleScript(String scriptPath) {
-        ScriptSchedule schedule = schedules.get(scriptPath);
-        if (schedule == null) {
-            return;
+    private void unscheduleScripts() {
+        for (String scriptPath : scheduled) {
+            try {
+                scheduler.unschedule(scriptPath);
+            } catch (Exception e) {
+                LOG.error("Cron schedule script '{}' cannot be unscheduled!", scriptPath, e);
+            }
         }
-        if (schedule instanceof BootSchedule) {
-            schedules.remove(scriptPath);
-        } else if (schedule instanceof CronSchedule) {
-            scheduler.unschedule(scriptPath);
-            schedules.remove(scriptPath);
-        } else {
-            throw new IllegalStateException(String.format("Schedule '%s' for script '%s' is not supported!", schedule.getId(), scriptPath));
+        scheduled.clear();
+    }
+
+    private void bootOrScheduleScripts() {
+        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+            scriptRepository.findAll(ScriptType.BOOT).forEach(script -> {
+                ScriptSchedule schedule = determineSchedule(script.getPath(), resourceResolver);
+                if (schedule instanceof BootSchedule) {
+                    bootScript(script);
+                } else if (schedule instanceof CronSchedule) {
+                    scheduleScript(script, (CronSchedule) schedule);
+                }
+            });
+        } catch (LoginException e) {
+            LOG.error("Cannot access repository while automatic scripts booting!", e);
         }
     }
 
-    private void scheduleScript(String scriptPath, ResourceResolver resourceResolver) {
-        ScriptSchedule schedule = determineSchedule(scriptPath, resourceResolver);
-        if (schedule instanceof BootSchedule) {
-            schedules.put(scriptPath, schedule);
-        } else if (schedule instanceof CronSchedule) {
-            CronSchedule cron = (CronSchedule) schedule;
-            if (StringUtils.isNotBlank(cron.getExpression())) {
-                scheduler.schedule(cronJob(scriptPath), configureSchedule(scriptPath, scheduler.EXPR(cron.getExpression())));
-                schedules.put(scriptPath, schedule);
+    // TODO what if script changed and was booted
+    private void bootScript(Script script) {
+        AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
+        if (!booted.get()) {
+            if (!checkScript(script)) {
+                LOG.info("Boot script '{}' is not ready for queueing!", script.getPath());
             } else {
-                LOG.error("Schedule '{}' for script '{}' has no cron expression defined!", schedule.getId(), scriptPath);
+                queueScript(script);
+                booted.set(true);
             }
-        } else {
-            throw new IllegalStateException(String.format("Schedule '%s' for script '%s' is not supported!", schedule.getId(), scriptPath));
         }
     }
 
-    private ScriptSchedule determineSchedule(String scriptPath, ResourceResolver resourceResolver) {
-        return new BootSchedule(); // call script's scheduleRun() method
-    }
-
-    private Job bootJob() {
-        return context -> {
-            HealthStatus healthStatus = null;
-            while (healthStatus == null || !healthStatus.isHealthy()) {
-                healthStatus = healthChecker.checkStatus();
-                if (healthStatus.isHealthy()) {
-                    break;
-                } else {
-                    LOG.warn("Boot scripts paused due to health status: {}", healthStatus);
-                    try {
-                        Thread.sleep(1000); // wait before retrying
-                    } catch (InterruptedException e) {
-                        Thread.currentThread().interrupt();
-                        LOG.error("Boot scripts queueing interrupted!", e);
-                    }
-                }
-            }
-            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-                ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-                for (Map.Entry<String, ScriptSchedule> e : schedules.entrySet()) {
-                    if (e.getValue() instanceof BootSchedule) {
-                        String scriptPath = e.getKey();
-                        BootSchedule boot = (BootSchedule) e.getValue();
-                        Script script = scriptRepository.read(scriptPath).orElse(null);
-                        if (script == null) {
-                            LOG.error("Boot script '{}' cannot be read while queueing!", scriptPath);
-                        } else if (!boot.getDone().get()) {
-                            if (!checkScript(script)) {
-                                LOG.info("Boot script '{}' is not ready for queueing!", scriptPath);
-                            } else {
-                                queueScript(script);
-                                boot.getDone().set(true);
-                            }
-                        }
-                    }
-                }
-            } catch (LoginException e) {
-                LOG.error("Cannot access repository while boot scripts queueing!", e);
-            }
-        };
+    private void scheduleScript(Script script, CronSchedule schedule) {
+        if (StringUtils.isNotBlank(schedule.getExpression())) {
+            scheduler.schedule(cronJob(script.getPath()), configureScheduleOptions(script.getPath(), scheduler.EXPR(schedule.getExpression())));
+            scheduled.add(script.getPath());
+        } else {
+            LOG.error("Cron schedule for script '{}' has no cron expression defined!", script.getPath());
+        }
     }
 
     private boolean checkScript(Script script) {
@@ -202,16 +204,16 @@ private Job cronJob(String scriptPath) {
                 ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
                 Script script = scriptRepository.read(scriptPath).orElse(null);
                 if (script == null) {
-                    LOG.warn("Script '{}' not found in repository, not queueing!", scriptPath);
+                    LOG.error("Cron schedule script '{}' not found in repository!", scriptPath);
                 } else {
                     if (checkScript(script)) {
                         queueScript(script);
                     } else {
-                        LOG.debug("Script '{}' is not ready for execution, not queueing!", scriptPath);
+                        LOG.warn("Cron schedule script '{}' is not ready for queueing!", scriptPath);
                     }
                 }
             } catch (LoginException e) {
-                LOG.error("Cannot access repository while queueing cron script execution '{}'!", scriptPath, e);
+                LOG.error("Cannot access repository while queueing cron schedule script '{}'!", scriptPath, e);
             }
         };
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
index a4151b58..f908291e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
@@ -7,7 +7,7 @@
 public enum ScriptType {
     MANUAL(ScriptRepository.ROOT + "/manual"),
     SCHEDULE(ScriptRepository.ROOT + "/schedule"),
-    BOOT(ScriptRepository.ROOT + "/boot"),
+    BOOT(ScriptRepository.ROOT + "/boot"), // TODO 'automatic
     MOCK(ScriptRepository.ROOT + "/mock"),
     EXTENSION(ScriptRepository.ROOT + "/extension"),
     TEMPLATE(ScriptRepository.ROOT + "/template");
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
index 7a4dfe49..98b52716 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
@@ -2,18 +2,10 @@
 
 import dev.vml.es.acm.core.script.ScriptSchedule;
 
-import java.util.concurrent.atomic.AtomicBoolean;
-
 public class BootSchedule implements ScriptSchedule {
 
-    private final AtomicBoolean done = new AtomicBoolean(false);
-
     @Override
     public String getId() {
         return "boot";
     }
-
-    public AtomicBoolean getDone() {
-        return done;
-    }
 }

From 36af16282a5bd5dacd5b8438528141ff32793f36 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 7 Aug 2025 10:04:42 +0200
Subject: [PATCH 15/83] Safe script execution

---
 aem/default/etc/aem.yml | 2 ++
 aemw                    | 2 +-
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/aem/default/etc/aem.yml b/aem/default/etc/aem.yml
index b6458949..a40e7e5f 100755
--- a/aem/default/etc/aem.yml
+++ b/aem/default/etc/aem.yml
@@ -166,6 +166,8 @@ instance:
     install_dependency_handling: required
     # Controls how 'rep:policy' nodes are handled during import
     install_ac_handling: ''
+    # Determines whether a copy of the package is created during installation (saves disk space but prevents uninstallation)
+    install_extract_only: snapshot
 
   # 'SSL By Default'
   ssl:
diff --git a/aemw b/aemw
index e0c9d361..3e3c975a 100755
--- a/aemw
+++ b/aemw
@@ -1,6 +1,6 @@
 #!/usr/bin/env sh
 
-VERSION=${AEM_CLI_VERSION:-"2.0.6"}
+VERSION=${AEM_CLI_VERSION:-"2.0.10"}
 
 # Define API
 # ==========

From fee2583ba518149ecd1298bb3b959cacfb419f35 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 11 Aug 2025 16:38:48 +0200
Subject: [PATCH 16/83] Safe script execution

---
 .../vml/es/acm/core/code/ExecutableUtils.java |   4 +-
 .../es/acm/core/code/ExecutionContext.java    |  10 +-
 .../dev/vml/es/acm/core/code/Executor.java    |  14 ++
 .../dev/vml/es/acm/core/code/Schedule.java    |   6 +
 .../vml/es/acm/core/code/ScheduleResult.java  |  21 +++
 .../dev/vml/es/acm/core/code/Schedules.java   |  15 ++
 .../acm/core/code/schedule/BootSchedule.java  |  11 ++
 .../schedule/CronSchedule.java                |   6 +-
 .../acm/core/code/script/ContentScript.java   |  15 ++
 .../core/code/script/ContentScriptSyntax.java |   2 +
 .../core/script/AutomaticScriptScheduler.java |  39 ++--
 .../dev/vml/es/acm/core/script/Script.java    |   2 +-
 .../es/acm/core/script/ScriptSchedule.java    |   6 -
 .../es/acm/core/script/ScriptScheduler.java   | 177 ------------------
 .../vml/es/acm/core/script/ScriptType.java    |   5 +-
 .../core/script/schedule/BootSchedule.java    |  11 --
 .../main/content/META-INF/vault/filter.xml    |   3 +-
 ...ptBootList.tsx => ScriptAutomaticList.tsx} |   6 +-
 .../src/components/ScriptScheduleList.tsx     | 137 --------------
 .../src/components/ScriptToggleButton.tsx     |   2 +-
 ui.frontend/src/pages/ScriptsPage.tsx         |  19 +-
 ui.frontend/src/utils/api.types.ts            |   3 +-
 22 files changed, 137 insertions(+), 377 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/Schedule.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/ScheduleResult.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/schedule/BootSchedule.java
 rename core/src/main/java/dev/vml/es/acm/core/{script => code}/schedule/CronSchedule.java (64%)
 delete mode 100644 core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
 delete mode 100644 core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
 delete mode 100644 core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
 rename ui.frontend/src/components/{ScriptBootList.tsx => ScriptAutomaticList.tsx} (97%)
 delete mode 100644 ui.frontend/src/components/ScriptScheduleList.tsx

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
index 1ec5a16e..cf2afa00 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
@@ -15,8 +15,8 @@ public static String nameById(String id) {
         if (Executable.ID_CONSOLE.equals(id)) {
             return "Console";
         }
-        if (StringUtils.startsWith(id, ScriptType.SCHEDULE.root() + "/")) {
-            return String.format("Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.SCHEDULE.root() + "/"));
+        if (StringUtils.startsWith(id, ScriptType.AUTOMATIC.root() + "/")) {
+            return String.format("Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.AUTOMATIC.root() + "/"));
         }
         if (StringUtils.startsWith(id, ScriptType.MANUAL.root() + "/")) {
             return String.format("Manual Script '%s'", StringUtils.removeStart(id, ScriptType.MANUAL.root() + "/"));
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java
index d637d4e3..c5bb7de0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java
@@ -25,6 +25,8 @@ public class ExecutionContext implements AutoCloseable {
 
     private final Arguments arguments;
 
+    private final Schedules schedules;
+
     private final Conditions conditions;
 
     public ExecutionContext(
@@ -36,8 +38,9 @@ public ExecutionContext(
         this.codeContext = codeContext;
         this.output = mode == ExecutionMode.RUN ? new CodeOutputFile(id) : new CodeOutputString();
         this.printStream = new CodePrintStream(output.write(), String.format("%s|%s", executable.getId(), id));
-        this.arguments = new Arguments();
+        this.schedules = new Schedules();
         this.conditions = new Conditions(this);
+        this.arguments = new Arguments();
 
         customizeBinding();
     }
@@ -98,6 +101,10 @@ public Arguments getArguments() {
         return arguments;
     }
 
+    public Schedules getSchedules() {
+        return schedules;
+    }
+
     public Conditions getConditions() {
         return conditions;
     }
@@ -105,6 +112,7 @@ public Conditions getConditions() {
     private void customizeBinding() {
         Binding binding = getCodeContext().getBinding();
 
+        binding.setVariable("schedules", schedules);
         binding.setVariable("arguments", arguments);
         binding.setVariable("conditions", conditions);
         binding.setVariable("out", getOut());
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index e4513ce9..6fe6e0d2 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -1,6 +1,7 @@
 package dev.vml.es.acm.core.code;
 
 import dev.vml.es.acm.core.AcmException;
+import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import dev.vml.es.acm.core.code.script.ContentScript;
 import dev.vml.es.acm.core.osgi.OsgiContext;
 import dev.vml.es.acm.core.util.ResourceUtils;
@@ -175,6 +176,19 @@ public Description describe(ExecutionContext context) {
         }
     }
 
+    public ScheduleResult schedule(ExecutionContext context) {
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+        try {
+            ContentScript contentScript = new ContentScript(context);
+            execution.start();
+            Schedule schedule = contentScript.schedule();
+            return new ScheduleResult(execution.end(ExecutionStatus.SUCCEEDED), schedule);
+        } catch (Throwable e) {
+            execution.error(e);
+            return new ScheduleResult(execution.end(ExecutionStatus.FAILED), new BootSchedule());
+        }
+    }
+
     public boolean isDebug() {
         return config.debug();
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Schedule.java b/core/src/main/java/dev/vml/es/acm/core/code/Schedule.java
new file mode 100644
index 00000000..2e492661
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Schedule.java
@@ -0,0 +1,6 @@
+package dev.vml.es.acm.core.code;
+
+public interface Schedule {
+
+    String getId();
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ScheduleResult.java b/core/src/main/java/dev/vml/es/acm/core/code/ScheduleResult.java
new file mode 100644
index 00000000..1929d263
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ScheduleResult.java
@@ -0,0 +1,21 @@
+package dev.vml.es.acm.core.code;
+
+public class ScheduleResult {
+
+    private final Execution execution;
+
+    private final Schedule schedule;
+
+    public ScheduleResult(Execution execution, Schedule schedule) {
+        this.execution = execution;
+        this.schedule = schedule;
+    }
+
+    public Execution getExecution() {
+        return execution;
+    }
+
+    public Schedule getSchedule() {
+        return schedule;
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java b/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
new file mode 100644
index 00000000..716e542b
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
@@ -0,0 +1,15 @@
+package dev.vml.es.acm.core.code;
+
+import dev.vml.es.acm.core.code.schedule.BootSchedule;
+import dev.vml.es.acm.core.code.schedule.CronSchedule;
+
+public class Schedules {
+
+    public BootSchedule boot() {
+        return new BootSchedule();
+    }
+
+    public CronSchedule cron(String cronExpression) {
+        return new CronSchedule(cronExpression);
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/schedule/BootSchedule.java b/core/src/main/java/dev/vml/es/acm/core/code/schedule/BootSchedule.java
new file mode 100644
index 00000000..7e250850
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/code/schedule/BootSchedule.java
@@ -0,0 +1,11 @@
+package dev.vml.es.acm.core.code.schedule;
+
+import dev.vml.es.acm.core.code.Schedule;
+
+public class BootSchedule implements Schedule {
+
+    @Override
+    public String getId() {
+        return "boot";
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java b/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
similarity index 64%
rename from core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java
rename to core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
index 19663308..c9cb6ad3 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/schedule/CronSchedule.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
@@ -1,8 +1,8 @@
-package dev.vml.es.acm.core.script.schedule;
+package dev.vml.es.acm.core.code.schedule;
 
-import dev.vml.es.acm.core.script.ScriptSchedule;
+import dev.vml.es.acm.core.code.Schedule;
 
-public class CronSchedule implements ScriptSchedule {
+public class CronSchedule implements Schedule {
 
     private final String expression;
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
index 36356013..4f157a74 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
@@ -1,6 +1,9 @@
 package dev.vml.es.acm.core.code.script;
 
+import dev.vml.es.acm.core.AcmException;
 import dev.vml.es.acm.core.code.*;
+import dev.vml.es.acm.core.code.Schedule;
+import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import groovy.lang.GroovyShell;
 import groovy.lang.MissingMethodException;
 import groovy.lang.Script;
@@ -24,11 +27,23 @@ private Script parseScript() {
                 executionContext.getCodeContext().getBinding());
     }
 
+    public Schedule schedule() {
+        try {
+            return (Schedule) script.invokeMethod(ContentScriptSyntax.Method.SCHEDULE.givenName, null);
+        } catch (MissingMethodException e) {
+            return new BootSchedule(); // intentional default schedule
+        } catch (Throwable e) {
+            throw new AcmException(String.format("Executable '%s' schedule method error!", executionContext.getExecutable().getId()), e);
+        }
+    }
+
     public void describe() {
         try {
             script.invokeMethod(ContentScriptSyntax.Method.DESCRIBE.givenName, null);
         } catch (MissingMethodException e) {
             // ignore as the method is optional
+        } catch (Throwable e) {
+            throw new AcmException(String.format("Executable '%s' describe method error!", executionContext.getExecutable().getId()), e);
         }
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScriptSyntax.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScriptSyntax.java
index 1cf8804b..84124d59 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScriptSyntax.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScriptSyntax.java
@@ -2,6 +2,7 @@
 
 import static dev.vml.es.acm.core.code.script.ScriptUtils.*;
 
+import dev.vml.es.acm.core.code.Schedule;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.control.CompilePhase;
@@ -37,6 +38,7 @@ public void visit(ASTNode[] nodes, SourceUnit source) {
     }
 
     enum Method {
+        SCHEDULE("scheduleRun", Schedule.class.getName(), false),
         DESCRIBE("describeRun", "void", false),
         RUN("doRun", "void", true),
         CHECK("canRun", "boolean", true);
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 93c3ee7e..0e58c9b9 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -1,13 +1,11 @@
 package dev.vml.es.acm.core.script;
 
-import dev.vml.es.acm.core.code.ExecutionContextOptions;
-import dev.vml.es.acm.core.code.ExecutionMode;
-import dev.vml.es.acm.core.code.ExecutionQueue;
+import dev.vml.es.acm.core.code.*;
 import dev.vml.es.acm.core.instance.HealthChecker;
 import dev.vml.es.acm.core.instance.HealthStatus;
 import dev.vml.es.acm.core.repo.Repo;
-import dev.vml.es.acm.core.script.schedule.BootSchedule;
-import dev.vml.es.acm.core.script.schedule.CronSchedule;
+import dev.vml.es.acm.core.code.schedule.BootSchedule;
+import dev.vml.es.acm.core.code.schedule.CronSchedule;
 import dev.vml.es.acm.core.util.ResourceUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.LoginException;
@@ -30,6 +28,8 @@
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import static dev.vml.es.acm.core.util.ServletResult.ok;
+
 @Component(
         service = ResourceChangeListener.class,
         immediate = true,
@@ -55,6 +55,9 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     @Reference
     private ResourceResolverFactory resourceResolverFactory;
 
+    @Reference
+    private Executor executor;
+
     @Reference
     private Scheduler scheduler;
 
@@ -111,8 +114,11 @@ private ScheduleOptions configureScheduleOptions(String name, ScheduleOptions op
         return options;
     }
 
-    private ScriptSchedule determineSchedule(String scriptPath, ResourceResolver resourceResolver) {
-        return new BootSchedule(); // TODO call script's scheduleRun() method
+    private ScheduleResult determineSchedule(Script script, ResourceResolver resourceResolver) {
+        try (ExecutionContext context = executor.createContext(
+                ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
+            return executor.schedule(context);
+        }
     }
 
     private Job bootJob() {
@@ -155,16 +161,21 @@ private void unscheduleScripts() {
     private void bootOrScheduleScripts() {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-            scriptRepository.findAll(ScriptType.BOOT).forEach(script -> {
-                ScriptSchedule schedule = determineSchedule(script.getPath(), resourceResolver);
-                if (schedule instanceof BootSchedule) {
-                    bootScript(script);
-                } else if (schedule instanceof CronSchedule) {
-                    scheduleScript(script, (CronSchedule) schedule);
+            scriptRepository.findAll(ScriptType.AUTOMATIC).forEach(script -> {
+                ScheduleResult scheduleResult = determineSchedule(script, resourceResolver);
+                if (scheduleResult.getExecution().getStatus() == ExecutionStatus.SUCCEEDED) {
+                    Schedule schedule = scheduleResult.getSchedule();
+                    if (schedule instanceof BootSchedule) {
+                        bootScript(script);
+                    } else if (schedule instanceof CronSchedule) {
+                        scheduleScript(script, (CronSchedule) schedule);
+                    }
+                } else {
+                    LOG.error("Automatic script schedule cannot be determined! {}", scheduleResult.getExecution());
                 }
             });
         } catch (LoginException e) {
-            LOG.error("Cannot access repository while automatic scripts booting!", e);
+            LOG.error("Cannot access repository while determining automatic script schedules!", e);
         }
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
index 4e3d0ef0..8ddb426b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
@@ -67,7 +67,7 @@ public String getPath() {
     }
 
     public ScriptType getType() {
-        return ScriptType.byPath(getPath()).orElse(ScriptType.BOOT);
+        return ScriptType.byPath(getPath()).orElse(ScriptType.AUTOMATIC);
     }
 
     public InputStream readContent() throws ScriptException {
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
deleted file mode 100644
index 29b81903..00000000
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptSchedule.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.vml.es.acm.core.script;
-
-public interface ScriptSchedule {
-
-    String getId();
-}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
deleted file mode 100644
index 23b9255f..00000000
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptScheduler.java
+++ /dev/null
@@ -1,177 +0,0 @@
-package dev.vml.es.acm.core.script;
-
-import dev.vml.es.acm.core.AcmException;
-import dev.vml.es.acm.core.code.*;
-import dev.vml.es.acm.core.instance.HealthChecker;
-import dev.vml.es.acm.core.instance.HealthStatus;
-import dev.vml.es.acm.core.util.ResourceUtils;
-import dev.vml.es.acm.core.util.quartz.CronExpression;
-import java.text.ParseException;
-import java.util.Date;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.apache.commons.lang3.StringUtils;
-import org.apache.sling.api.resource.ResourceResolver;
-import org.apache.sling.api.resource.ResourceResolverFactory;
-import org.apache.sling.commons.scheduler.Scheduler;
-import org.osgi.service.component.annotations.Activate;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Modified;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.metatype.annotations.AttributeDefinition;
-import org.osgi.service.metatype.annotations.Designate;
-import org.osgi.service.metatype.annotations.ObjectClassDefinition;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Component(
-        immediate = true,
-        service = {Runnable.class, ScriptScheduler.class},
-        property = {Scheduler.PROPERTY_SCHEDULER_RUN_ON + "=" + Scheduler.VALUE_RUN_ON_LEADER})
-@Designate(ocd = ScriptScheduler.Config.class)
-public class ScriptScheduler implements Runnable {
-
-    private static final Logger LOG = LoggerFactory.getLogger(ScriptScheduler.class);
-
-    @ObjectClassDefinition(name = "AEM Content Manager - Script Scheduler")
-    public @interface Config {
-
-        @AttributeDefinition(name = "Enabled", description = "Allows to temporarily disable the script executor")
-        boolean enabled() default true;
-
-        @AttributeDefinition(
-                name = "Scheduler Expression",
-                description =
-                        "How often the scripts should be executed. Default is every minute (0 * * * * ?). Quartz cron expression format.")
-        String scheduler_expression() default "0 * * * * ?";
-
-        @AttributeDefinition(
-                name = "User Impersonation ID",
-                description =
-                        "Controls who accesses the repository when scripts are automatically executed. If blank, the service user is used.")
-        String userImpersonationId();
-    }
-
-    @Reference
-    private ResourceResolverFactory resourceResolverFactory;
-
-    @Reference
-    private Executor executor;
-
-    @Reference
-    private HealthChecker healthChecker;
-
-    @Reference
-    private ExecutionQueue queue;
-
-    private Config config;
-
-    private long intervalMillis;
-
-    private final AtomicLong runCount = new AtomicLong(0);
-
-    private final AtomicBoolean boot = new AtomicBoolean(true);
-
-    @Activate
-    @Modified
-    protected void activate(Config config) {
-        this.config = config;
-        this.intervalMillis = calculateInterval();
-    }
-
-    private long calculateInterval() {
-        try {
-            CronExpression expression = new CronExpression(this.config.scheduler_expression());
-            Date now = new Date();
-            Date nextRun = expression.getNextValidTimeAfter(now);
-            Date secondRun = expression.getNextValidTimeAfter(nextRun);
-            return secondRun.getTime() - nextRun.getTime();
-        } catch (ParseException e) {
-            throw new AcmException("Script scheduler interval cannot be parsed!", e);
-        }
-    }
-
-    @Override
-    public void run() {
-        if (!config.enabled()) {
-            LOG.debug("Script scheduler is disabled");
-            return;
-        }
-        HealthStatus healthStatus = healthChecker.checkStatus();
-        if (!healthStatus.isHealthy()) {
-            LOG.warn("Script scheduler is paused due to health status: {}", healthStatus);
-            return;
-        }
-        List<Execution> bootScripts = findQueuedBootScripts().collect(Collectors.toList());
-        if (!bootScripts.isEmpty()) {
-            LOG.info("Script scheduler is paused due to queued boot scripts ({}): {}", bootScripts.size(), StringUtils.join(bootScripts, ", "));
-            return;
-        }
-        queueScripts();
-    }
-
-    private void queueScripts() {
-        String userId =
-                StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
-        ExecutionContextOptions contextOptions = new ExecutionContextOptions(ExecutionMode.RUN, userId);
-        try (ResourceResolver resourceResolver =
-                ResourceUtils.contentResolver(resourceResolverFactory, contextOptions.getUserId())) {
-            if (boot.compareAndSet(true, false)) {
-                queueScripts(ScriptType.BOOT, resourceResolver, contextOptions);
-            } else {
-                queueScripts(ScriptType.SCHEDULE, resourceResolver, contextOptions);
-            }
-            runCount.incrementAndGet();
-        } catch (Exception e) {
-            LOG.error("Cannot access repository while scheduling enabled scripts to execution queue!", e);
-        }
-    }
-
-    private void queueScripts(ScriptType type, ResourceResolver resourceResolver, ExecutionContextOptions contextOptions) {
-        ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-        scriptRepository.findAll(type).forEach(script -> {
-            if (checkScript(script, resourceResolver)) {
-                queueScript(script, contextOptions);
-            }
-        });
-    }
-
-    private boolean checkScript(Script script, ResourceResolver resourceResolver) {
-        try (ExecutionContext context =
-                executor.createContext(ExecutionId.generate(), ExecutionMode.CHECK, script, resourceResolver)) {
-            Execution execution = executor.execute(context);
-            LOG.debug("Script checked '{}'", execution);
-            return execution.getStatus() != ExecutionStatus.SKIPPED;
-        } catch (Exception e) {
-            LOG.error("Cannot check script '{}' while scheduling to execution queue!", script.getId(), e);
-            return false;
-        }
-    }
-
-    private void queueScript(Script script, ExecutionContextOptions contextOptions) {
-        try {
-            queue.submit(script, contextOptions);
-        } catch (Exception e) {
-            LOG.error("Cannot submit script '{}' to execution queue!", script.getId(), e);
-        }
-    }
-
-    private Stream<Execution> findQueuedBootScripts() {
-        return queue.findAll().filter(execution -> {
-            ScriptType type = ScriptType.byPath(execution.getExecutable().getId()).orElse(null);
-            return ScriptType.BOOT.equals(type);
-        });
-    }
-
-    public long getIntervalMillis() {
-        return this.intervalMillis;
-    }
-
-    public long getRunCount() {
-        return this.runCount.get();
-    }
-}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
index f908291e..946e6bd1 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptType.java
@@ -6,8 +6,7 @@
 
 public enum ScriptType {
     MANUAL(ScriptRepository.ROOT + "/manual"),
-    SCHEDULE(ScriptRepository.ROOT + "/schedule"),
-    BOOT(ScriptRepository.ROOT + "/boot"), // TODO 'automatic
+    AUTOMATIC(ScriptRepository.ROOT + "/automatic"),
     MOCK(ScriptRepository.ROOT + "/mock"),
     EXTENSION(ScriptRepository.ROOT + "/extension"),
     TEMPLATE(ScriptRepository.ROOT + "/template");
@@ -31,7 +30,7 @@ public static Optional<ScriptType> byPath(String path) {
     }
 
     public boolean statsSupported() {
-        return this == MANUAL || this == SCHEDULE || this == BOOT;
+        return this == MANUAL || this == AUTOMATIC;
     }
 
     public String root() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java b/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
deleted file mode 100644
index 98b52716..00000000
--- a/core/src/main/java/dev/vml/es/acm/core/script/schedule/BootSchedule.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.vml.es.acm.core.script.schedule;
-
-import dev.vml.es.acm.core.script.ScriptSchedule;
-
-public class BootSchedule implements ScriptSchedule {
-
-    @Override
-    public String getId() {
-        return "boot";
-    }
-}
diff --git a/ui.content.example/src/main/content/META-INF/vault/filter.xml b/ui.content.example/src/main/content/META-INF/vault/filter.xml
index e9e51daa..e0fc9369 100644
--- a/ui.content.example/src/main/content/META-INF/vault/filter.xml
+++ b/ui.content.example/src/main/content/META-INF/vault/filter.xml
@@ -1,8 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <workspaceFilter version="1.0">
     <filter root="/conf/acm/settings/script/manual/example"/>
-    <filter root="/conf/acm/settings/script/boot/example"/>
-    <filter root="/conf/acm/settings/script/schedule/example"/>
+    <filter root="/conf/acm/settings/script/automatic/example"/>
     <filter root="/conf/acm/settings/script/extension/example"/>
     <filter root="/conf/acm/settings/script/mock/example"/>
 </workspaceFilter>
diff --git a/ui.frontend/src/components/ScriptBootList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
similarity index 97%
rename from ui.frontend/src/components/ScriptBootList.tsx
rename to ui.frontend/src/components/ScriptAutomaticList.tsx
index 51203ef2..1ee5ae99 100644
--- a/ui.frontend/src/components/ScriptBootList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -16,8 +16,8 @@ import ScriptToggleButton from './ScriptToggleButton';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
 
-const ScriptBootList: React.FC = () => {
-  const type = ScriptType.BOOT;
+const ScriptAutomaticList: React.FC = () => {
+  const type = ScriptType.AUTOMATIC;
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
@@ -133,4 +133,4 @@ const ScriptBootList: React.FC = () => {
   );
 };
 
-export default ScriptBootList;
+export default ScriptAutomaticList;
diff --git a/ui.frontend/src/components/ScriptScheduleList.tsx b/ui.frontend/src/components/ScriptScheduleList.tsx
deleted file mode 100644
index afc611f2..00000000
--- a/ui.frontend/src/components/ScriptScheduleList.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
-import { Key, Selection } from '@react-types/shared';
-import NotFound from '@spectrum-icons/illustrations/NotFound';
-import Magnify from '@spectrum-icons/workflow/Magnify';
-import React, { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useAppState } from '../hooks/app';
-import { useFormatter } from '../hooks/formatter';
-import { useScripts } from '../hooks/script';
-import { InstanceRole, isExecutionNegative, ScriptType } from '../utils/api.types';
-import DateExplained from './DateExplained';
-import ExecutionStatsBadge from './ExecutionStatsBadge';
-import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
-import ScriptSynchronizeButton from './ScriptSynchronizeButton';
-import ScriptToggleButton from './ScriptToggleButton';
-import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
-import UserInfo from './UserInfo';
-
-const ScriptScheduleList: React.FC = () => {
-  const type = ScriptType.SCHEDULE;
-  const appState = useAppState();
-  const navigate = useNavigate();
-  const formatter = useFormatter();
-
-  const { scripts, loading, loadScripts } = useScripts(type);
-
-  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
-  const selectedIds = (selectedKeys: Selection): string[] => {
-    if (selectedKeys === 'all') {
-      return scripts?.list.map((script) => script.id) || [];
-    } else {
-      return Array.from(selectedKeys as Set<Key>).map((key) => key.toString());
-    }
-  };
-
-  const renderEmptyState = () => (
-    <IllustratedMessage>
-      <NotFound />
-      <Content>No scripts found</Content>
-    </IllustratedMessage>
-  );
-
-  if (scripts === null || loading) {
-    return (
-      <Flex flex="1" justifyContent="center" alignItems="center">
-        <ProgressBar label="Loading..." isIndeterminate />
-      </Flex>
-    );
-  }
-
-  return (
-    <Flex direction="column" flex="1" gap="size-200" marginY="size-100">
-      <View>
-        <Flex direction="row" justifyContent="space-between" alignItems="center">
-          <Flex flex="1" alignItems="center">
-            <ButtonGroup>
-              <ScriptToggleButton type={type} selectedKeys={selectedIds(selectedKeys)} onToggle={loadScripts} />
-              {appState.instanceSettings.role == InstanceRole.AUTHOR && <ScriptSynchronizeButton selectedKeys={selectedIds(selectedKeys)} onSync={loadScripts} />}
-            </ButtonGroup>
-          </Flex>
-          <Flex flex="1" justifyContent="center" alignItems="center">
-            <ScriptExecutorStatusLight />
-          </Flex>
-          <Flex flex="1" justifyContent="end" alignItems="center">
-            <ScriptsAutomaticHelpButton />
-          </Flex>
-        </Flex>
-      </View>
-      <TableView
-        flex="1"
-        aria-label={`Script list (${type})`}
-        selectionMode={'multiple'}
-        selectedKeys={selectedKeys}
-        onSelectionChange={setSelectedKeys}
-        renderEmptyState={renderEmptyState}
-        onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
-      >
-        <TableHeader>
-          <Column width="4fr">Name</Column>
-          <Column width="5fr">Last Execution</Column>
-          <Column width="3fr">
-            <Text>Average Duration</Text>
-            <ContextualHelp variant="info">
-              <Heading>Explanation</Heading>
-              <Content>Duration is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
-            </ContextualHelp>
-          </Column>
-          <Column width="2fr" align="end">
-            <Text>Success Rate</Text>
-            <ContextualHelp variant="info">
-              <Heading>Explanation</Heading>
-              <Content>Success rate is calculated based on the last {appState.spaSettings.scriptStatsLimit} completed executions (only succeeded or failed).</Content>
-            </ContextualHelp>
-          </Column>
-        </TableHeader>
-        <TableBody>
-          {(scripts.list || []).map((script) => {
-            const scriptStats = scripts.stats.find((stat) => stat.path === script.id)!;
-            const lastExecution = scriptStats?.lastExecution;
-
-            return (
-              <Row key={script.id}>
-                <Cell>{script.name}</Cell>
-                <Cell>
-                  <Flex alignItems="center" gap="size-100">
-                    {lastExecution ? (
-                      <>
-                        <Button variant={isExecutionNegative(lastExecution.status) ? 'negative' : 'secondary'} onPress={() => navigate(`/executions/view/${encodeURIComponent(lastExecution.id)}`)} aria-label="View Execution">
-                          <Magnify />
-                        </Button>
-                        <Text>
-                          <DateExplained value={lastExecution.startDate} />
-                        </Text>
-                        <Text>by</Text>
-                        <UserInfo id={lastExecution.userId} />
-                      </>
-                    ) : (
-                      <Text>&mdash;</Text>
-                    )}
-                  </Flex>
-                </Cell>
-                <Cell>
-                  <Text>{lastExecution ? formatter.duration(scriptStats.averageDuration) : <>&mdash;</>}</Text>
-                </Cell>
-                <Cell>
-                  <ExecutionStatsBadge stats={scriptStats} />
-                </Cell>
-              </Row>
-            );
-          })}
-        </TableBody>
-      </TableView>
-    </Flex>
-  );
-};
-
-export default ScriptScheduleList;
diff --git a/ui.frontend/src/components/ScriptToggleButton.tsx b/ui.frontend/src/components/ScriptToggleButton.tsx
index 349a1601..eb4d8a6c 100644
--- a/ui.frontend/src/components/ScriptToggleButton.tsx
+++ b/ui.frontend/src/components/ScriptToggleButton.tsx
@@ -6,7 +6,7 @@ import { toastRequest } from '../utils/api';
 import { ScriptType } from '../utils/api.types';
 
 type ScriptToggleButtonProps = {
-  type: ScriptType.SCHEDULE | ScriptType.BOOT;
+  type: ScriptType.SCHEDULE | ScriptType.AUTOMATIC;
   selectedKeys: string[];
   onToggle: () => void;
 };
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index 3183bf7c..225a9ae6 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -2,10 +2,8 @@ import { Flex, Item, TabList, TabPanels, Tabs, Text } from '@adobe/react-spectru
 import Beaker from '@spectrum-icons/workflow/Beaker';
 import Launch from '@spectrum-icons/workflow/Launch';
 import Extension from '@spectrum-icons/workflow/Extension';
-import Calendar from '@spectrum-icons/workflow/Calendar';
 import Hand from '@spectrum-icons/workflow/Hand';
-import ScriptBootList from '../components/ScriptBootList.tsx';
-import ScriptScheduleList from '../components/ScriptScheduleList.tsx';
+import ScriptAutomaticList from '../components/ScriptAutomaticList';
 import ScriptExtensionList from '../components/ScriptExtensionList';
 import ScriptManualList from '../components/ScriptManualList';
 import ScriptMockList from '../components/ScriptMockList';
@@ -25,13 +23,9 @@ const ScriptsPage = () => {
             <Hand />
             <Text>Manual</Text>
           </Item>
-          <Item aria-label="Boot scripts" key="boot">
+          <Item aria-label="Automatic scripts" key="automatic">
             <Launch />
-            <Text>Boot</Text>
-          </Item>
-          <Item aria-label="Schedule scripts" key="schedule">
-            <Calendar/>
-            <Text>Schedule</Text>
+            <Text>Automatic</Text>
           </Item>
           {appState.mockStatus.enabled ? (
             <Item aria-label="Mock scripts" key="mock">
@@ -48,11 +42,8 @@ const ScriptsPage = () => {
           <Item key="manual" aria-label="Manual Script List">
             <ScriptManualList />
           </Item>
-          <Item key="boot" aria-label="Boot Script List">
-            <ScriptBootList />
-          </Item>
-          <Item key="schedule" aria-label="Schedule Script List">
-            <ScriptScheduleList />
+          <Item key="automatic" aria-label="Automatic Script List">
+            <ScriptAutomaticList />
           </Item>
           {appState.mockStatus.enabled ? (
             <Item key="mock" aria-label="Mock">
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 991108f2..429a548c 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -288,8 +288,7 @@ export type Snippet = {
 };
 
 export enum ScriptType {
-  BOOT = 'BOOT',
-  SCHEDULE = 'SCHEDULE',
+  AUTOMATIC = 'AUTOMATIC',
   MANUAL = 'MANUAL',
   EXTENSION = 'EXTENSION',
   MOCK = 'MOCK',

From 151beac8a1eaad8dcf9d36d2a9fd3e9afe849402 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 11 Aug 2025 20:53:14 +0200
Subject: [PATCH 17/83] Safe script execution

---
 .../dev/vml/es/acm/core/code/Conditions.java  | 389 +-----------------
 .../dev/vml/es/acm/core/code/Executor.java    |   2 +-
 .../dev/vml/es/acm/core/osgi/OsgiContext.java |   5 -
 .../core/script/AutomaticScriptScheduler.java |  10 +-
 .../src/components/ScriptAutomaticList.tsx    |  25 +-
 .../src/components/ScriptToggleButton.tsx     |  80 ----
 6 files changed, 14 insertions(+), 497 deletions(-)
 delete mode 100644 ui.frontend/src/components/ScriptToggleButton.tsx

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
index aa432467..1c7fd22e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
@@ -3,14 +3,10 @@
 import dev.vml.es.acm.core.osgi.InstanceInfo;
 import dev.vml.es.acm.core.osgi.InstanceType;
 import dev.vml.es.acm.core.script.ScriptRepository;
-import dev.vml.es.acm.core.script.ScriptScheduler;
 import dev.vml.es.acm.core.script.ScriptType;
-import dev.vml.es.acm.core.util.DateUtils;
 import java.time.*;
-import java.time.temporal.ChronoUnit;
 import java.util.Date;
 import java.util.Iterator;
-import java.util.Locale;
 import java.util.Optional;
 import java.util.stream.Stream;
 import org.apache.commons.io.FilenameUtils;
@@ -65,344 +61,12 @@ public boolean notSucceeded() {
         return false; // TODO implement this
     }
 
-    public boolean notRunning() {
-        if (executionContext.getExecutor().isLocking() && lockedAny()) {
-            return false;
-        }
-        return noneRunning(queuedExecutions());
-    }
-
-    public boolean notRunningSelf() {
-        if (executionContext.getExecutor().isLocking() && lockedAny()) {
-            return false;
-        }
-        return noneRunning(queuedSelfExecutions());
-    }
-
-    private boolean noneRunning(Stream<Execution> queuedExecutions) {
-        return queuedExecutions.noneMatch(e -> e.getStatus() == ExecutionStatus.RUNNING);
-    }
-
-    public Stream<Execution> queuedExecutions() {
-        return getExecutionQueue().findAll().filter(e -> !isSelfExecution(e));
-    }
-
-    public Stream<Execution> queuedSelfExecutions() {
-        return getExecutionQueue().findAll().filter(e -> !isSelfExecution(e) && isSameExecutable(e));
-    }
-
-    public boolean isSelfExecution(Execution e) {
-        return StringUtils.equals(e.getId(), executionContext.getId());
-    }
-
-    public boolean isSameExecutable(Execution e) {
-        return StringUtils.equals(
-                e.getExecutable().getId(), executionContext.getExecutable().getId());
-    }
-
     public boolean isChangedExecutableContent(Execution execution) {
         return !StringUtils.equals(
                 execution.getExecutable().getContent(),
                 executionContext.getExecutable().getContent());
     }
 
-    private ExecutionQueue getExecutionQueue() {
-        return executionContext.getCodeContext().getOsgiContext().getExecutionQueue();
-    }
-
-    // Date-time-based
-
-    public boolean isDate(String dateString) {
-        ZonedDateTime localDateTime = DateUtils.zonedDateTimeFromString(dateString);
-        return isDate(localDateTime);
-    }
-
-    public boolean isDate(ZonedDateTime zonedDateTime) {
-        LocalDateTime localDateTime =
-                zonedDateTime.withZoneSameLocal(DateUtils.ZONE_ID).toLocalDateTime();
-        return isDate(localDateTime);
-    }
-
-    public boolean isDate(LocalDateTime localDateTime) {
-        LocalDateTime now = LocalDateTime.now();
-        LocalDateTime from = localDateTime;
-        LocalDateTime to = from.plus(getScriptScheduler().getIntervalMillis(), ChronoUnit.MILLIS);
-        return !now.isBefore(from) && !now.isAfter(to);
-    }
-
-    private ScriptScheduler getScriptScheduler() {
-        return executionContext.getCodeContext().getOsgiContext().getScriptScheduler();
-    }
-
-    public boolean isWeekend() {
-        DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();
-        return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
-    }
-
-    public boolean isWeekday() {
-        DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();
-        return dayOfWeek != DayOfWeek.SATURDAY && dayOfWeek != DayOfWeek.SUNDAY;
-    }
-
-    public boolean isDay(String day) {
-        DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();
-        return dayOfWeek.name().equalsIgnoreCase(day);
-    }
-
-    public boolean isDay(DayOfWeek day) {
-        return LocalDate.now().getDayOfWeek() == day;
-    }
-
-    public boolean isMonday() {
-        return isDay(DayOfWeek.MONDAY);
-    }
-
-    public boolean isTuesday() {
-        return isDay(DayOfWeek.TUESDAY);
-    }
-
-    public boolean isWednesday() {
-        return isDay(DayOfWeek.WEDNESDAY);
-    }
-
-    public boolean isThursday() {
-        return isDay(DayOfWeek.THURSDAY);
-    }
-
-    public boolean isFriday() {
-        return isDay(DayOfWeek.FRIDAY);
-    }
-
-    public boolean isSaturday() {
-        return isDay(DayOfWeek.SATURDAY);
-    }
-
-    public boolean isSunday() {
-        return isDay(DayOfWeek.SUNDAY);
-    }
-
-    public LocalTime dayStartTime() {
-        return LocalTime.MIDNIGHT;
-    }
-
-    public LocalTime dayEndTime() {
-        return LocalTime.of(23, 59, 59, 999999999);
-    }
-
-    public boolean everyMinuteStart() {
-        return everyMinuteAt(0);
-    }
-
-    public boolean everyMinuteAt(int second) {
-        LocalTime now = LocalTime.now();
-        LocalDateTime scheduledDateTime =
-                LocalDate.now().atTime(now.withSecond(second).withNano(0));
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyHourStart() {
-        return everyHourAt(0);
-    }
-
-    public boolean everyHourAt(int minute) {
-        LocalTime now = LocalTime.now();
-        LocalDateTime scheduledDateTime =
-                LocalDate.now().atTime(now.withMinute(minute).withSecond(0).withNano(0));
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyDayStart() {
-        return everyDayAt(LocalTime.MIDNIGHT);
-    }
-
-    public boolean everyDayAt(LocalTime time) {
-        LocalDateTime scheduledDateTime = LocalDate.now().atTime(time);
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyDayAt(String time) {
-        return everyDayAt(parseTime(time));
-    }
-
-    public boolean everyWeekStart() {
-        return everyWeekAt(DayOfWeek.MONDAY, LocalTime.MIDNIGHT);
-    }
-
-    public boolean everyWeekAt(DayOfWeek dayOfWeek, LocalTime time) {
-        LocalDate startOfWeek = LocalDate.now().with(dayOfWeek);
-        LocalDateTime scheduledDateTime = startOfWeek.atTime(time);
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyWeekAt(String dayOfWeek, String time) {
-        return everyWeekAt(parseDayOfWeek(dayOfWeek), parseTime(time));
-    }
-
-    public boolean everyMonthStart() {
-        return everyMonthAt(1, LocalTime.MIDNIGHT);
-    }
-
-    public boolean everyMonthAt(int dayOfMonth) {
-        return everyMonthAt(dayOfMonth, LocalTime.MIDNIGHT);
-    }
-
-    public boolean everyMonthAt(int dayOfMonth, LocalTime time) {
-        LocalDate startOfMonth = LocalDate.now().withDayOfMonth(dayOfMonth);
-        LocalDateTime scheduledDateTime = startOfMonth.atTime(time);
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyMonthAt(int dayOfMonth, String time) {
-        return everyMonthAt(dayOfMonth, parseTime(time));
-    }
-
-    public boolean everyYearStart() {
-        return everyYearAt(Month.JANUARY, 1, LocalTime.MIDNIGHT);
-    }
-
-    public boolean everyYearAt(Month month, int dayOfMonth, LocalTime time) {
-        LocalDate targetDay = LocalDate.now().withMonth(month.getValue()).withDayOfMonth(dayOfMonth);
-        LocalDateTime scheduledDateTime = targetDay.atTime(time);
-        return isDate(scheduledDateTime);
-    }
-
-    public boolean everyYearAt(String month, int dayOfMonth, String time) {
-        return everyYearAt(parseMonth(month), dayOfMonth, parseTime(time));
-    }
-
-    private LocalTime parseTime(String time) {
-        return LocalTime.parse(time);
-    }
-
-    private Month parseMonth(String month) {
-        return Month.valueOf(month.trim().toUpperCase(Locale.ENGLISH));
-    }
-
-    private DayOfWeek parseDayOfWeek(String dayOfWeek) {
-        return DayOfWeek.valueOf(dayOfWeek.trim().toUpperCase(Locale.ENGLISH));
-    }
-
-    public boolean everyMinuteInRange() {
-        LocalTime now = LocalTime.now();
-        LocalTime startTime = now.withSecond(0).withNano(0);
-        LocalTime endTime = now.withSecond(59).withNano(999999999);
-        checkStartAndEndTime(startTime, endTime);
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        return !executedInRange(LocalDate.now(), startTime, endTime);
-    }
-
-    public boolean everyMinuteInRange(int startSecond, int endSecond) {
-        LocalTime now = LocalTime.now();
-        LocalTime startTime = now.withSecond(startSecond).withNano(0);
-        LocalTime endTime = now.withSecond(endSecond).withNano(999999999);
-        checkStartAndEndTime(startTime, endTime);
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        return !executedInRange(LocalDate.now(), startTime, endTime);
-    }
-
-    public boolean everyHourInRange() {
-        LocalTime now = LocalTime.now();
-        LocalTime startTime = now.withMinute(0).withSecond(0).withNano(0);
-        LocalTime endTime = now.withMinute(59).withSecond(59).withNano(999999999);
-        checkStartAndEndTime(startTime, endTime);
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        return !executedInRange(LocalDate.now(), startTime, endTime);
-    }
-
-    public boolean everyHourInRange(int startMinute, int endMinute) {
-        LocalTime now = LocalTime.now();
-        LocalTime startTime = now.withMinute(startMinute).withSecond(0).withNano(0);
-        LocalTime endTime = now.withMinute(endMinute).withSecond(59).withNano(999999999);
-        checkStartAndEndTime(startTime, endTime);
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        return !executedInRange(LocalDate.now(), startTime, endTime);
-    }
-
-    public boolean everyDayInRange() {
-        return everyDayInRange(dayStartTime(), dayEndTime());
-    }
-
-    public boolean everyDayInRange(LocalTime startTime, LocalTime endTime) {
-        checkStartAndEndTime(startTime, endTime);
-        LocalTime now = LocalTime.now();
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        return !executedInRange(LocalDate.now(), startTime, endTime);
-    }
-
-    public boolean everyDayInRange(String startTime, String endTime) {
-        return everyDayInRange(LocalTime.parse(startTime), LocalTime.parse(endTime));
-    }
-
-    public boolean everyWeekInRange() {
-        return everyWeekInRange(dayStartTime(), dayEndTime());
-    }
-
-    public boolean everyWeekInRange(LocalTime startTime, LocalTime endTime) {
-        checkStartAndEndTime(startTime, endTime);
-        LocalTime now = LocalTime.now();
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        LocalDate today = LocalDate.now();
-        LocalDate startOfWeek = today.with(DayOfWeek.MONDAY);
-        LocalDate endOfWeek = today.with(DayOfWeek.SUNDAY);
-        return !executedInRange(startOfWeek, startTime, endOfWeek, endTime);
-    }
-
-    public boolean everyWeekInRange(String startTime, String endTime) {
-        return everyWeekInRange(LocalTime.parse(startTime), LocalTime.parse(endTime));
-    }
-
-    public boolean everyMonthInRange() {
-        return everyMonthInRange(dayStartTime(), dayEndTime());
-    }
-
-    public boolean everyMonthInRange(LocalTime startTime, LocalTime endTime) {
-        checkStartAndEndTime(startTime, endTime);
-        LocalTime now = LocalTime.now();
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        LocalDate today = LocalDate.now();
-        LocalDate startOfMonth = today.withDayOfMonth(1);
-        LocalDate endOfMonth = today.withDayOfMonth(today.lengthOfMonth());
-        return !executedInRange(startOfMonth, startTime, endOfMonth, endTime);
-    }
-
-    public boolean everyMonthInRange(String startTime, String endTime) {
-        return everyMonthInRange(LocalTime.parse(startTime), LocalTime.parse(endTime));
-    }
-
-    public boolean everyYearInRange() {
-        return everyYearInRange(dayStartTime(), dayEndTime());
-    }
-
-    public boolean everyYearInRange(LocalTime startTime, LocalTime endTime) {
-        checkStartAndEndTime(startTime, endTime);
-        LocalTime now = LocalTime.now();
-        if (now.isBefore(startTime) || now.isAfter(endTime)) {
-            return false;
-        }
-        LocalDate today = LocalDate.now();
-        LocalDate startOfYear = today.withDayOfYear(1);
-        LocalDate endOfYear = today.withDayOfYear(today.lengthOfYear());
-        return !executedInRange(startOfYear, startTime, endOfYear, endTime);
-    }
-
-    public boolean everyYearInRange(String startTime, String endTime) {
-        return everyYearInRange(LocalTime.parse(startTime), LocalTime.parse(endTime));
-    }
-
     public boolean executedInRange(LocalDate date, LocalTime startTime, LocalTime endTime) {
         return executedInRange(date, startTime, date, endTime);
     }
@@ -424,10 +88,10 @@ public boolean executedInRange(String startDateTime, String endDateTime) {
     }
 
     public boolean executedInRange(LocalDateTime startDateTime, LocalDateTime endDateTime) {
-        return passedExecutionsInTimeRange(startDateTime, endDateTime).findAny().isPresent();
+        return passedExecutionsInRange(startDateTime, endDateTime).findAny().isPresent();
     }
 
-    public Stream<Execution> passedExecutionsInTimeRange(LocalDateTime startDateTime, LocalDateTime endDateTime) {
+    public Stream<Execution> passedExecutionsInRange(LocalDateTime startDateTime, LocalDateTime endDateTime) {
         checkStartAndEndDateTime(startDateTime, endDateTime);
         ExecutionQuery query = new ExecutionQuery();
         query.setExecutableId(executionContext.getExecutable().getId());
@@ -437,13 +101,6 @@ public Stream<Execution> passedExecutionsInTimeRange(LocalDateTime startDateTime
         return executionHistory.findAll(query);
     }
 
-    private void checkStartAndEndTime(LocalTime start, LocalTime end) {
-        if (start.isAfter(end)) {
-            throw new IllegalArgumentException(
-                    String.format("Start time '%s' must be before end time '%s'!", start, end));
-        }
-    }
-
     private void checkStartAndEndDateTime(LocalDateTime start, LocalDateTime end) {
         if (start.isAfter(end)) {
             throw new IllegalArgumentException(
@@ -481,53 +138,16 @@ private void checkDuration(Duration duration) {
         }
     }
 
-    // Run-count-based
-
-    public boolean everyNthRun(long frequency) {
-        if (frequency < 1) {
-            throw new IllegalArgumentException("Run frequency must be greater than zero!");
-        }
-        return runCount() % frequency == 0;
-    }
-
-    public long runCount() {
-        return getScriptScheduler().getRunCount();
-    }
-
     // Lock-based
 
-    public boolean unlockedSelf() {
-        return !lockedSelf();
-    }
-
-    public boolean unlockedAll() {
-        return !lockedAny();
-    }
-
-    public boolean lockedSelf() {
-        return locked(executionContext.getExecutable().getId());
-    }
-
     public boolean unlocked(String name) {
         return !locked(name);
     }
 
     public boolean locked(String name) {
-        requireLocking();
         return executionContext.getCodeContext().getLocker().isLocked(name);
     }
 
-    public boolean lockedAny() {
-        requireLocking();
-        return executionContext.getCodeContext().getLocker().anyLocked();
-    }
-
-    private void requireLocking() {
-        if (!executionContext.getExecutor().isLocking()) {
-            throw new IllegalStateException("Executor locking is disabled, so cannot check if lock exists!");
-        }
-    }
-
     // Instance-based
 
     public boolean instanceChanged() {
@@ -592,7 +212,7 @@ public boolean isConsole() {
     }
 
     public boolean isAutomaticScript() {
-        return StringUtils.startsWith(executableId(), ScriptType.SCHEDULE.root() + "/");
+        return StringUtils.startsWith(executableId(), ScriptType.AUTOMATIC.root() + "/");
     }
 
     public boolean isManualScript() {
@@ -617,9 +237,6 @@ public boolean retry(long count) {
         if (count < 1) {
             throw new IllegalArgumentException("Retry count must be greater than zero!");
         }
-        if (!notRunningSelf()) {
-            return false;
-        }
         Execution passedExecution = passedExecution();
         if (passedExecution == null) {
             return true;
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 6fe6e0d2..8060f169 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -185,7 +185,7 @@ public ScheduleResult schedule(ExecutionContext context) {
             return new ScheduleResult(execution.end(ExecutionStatus.SUCCEEDED), schedule);
         } catch (Throwable e) {
             execution.error(e);
-            return new ScheduleResult(execution.end(ExecutionStatus.FAILED), new BootSchedule());
+            return new ScheduleResult(execution.end(ExecutionStatus.FAILED), null);
         }
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/osgi/OsgiContext.java b/core/src/main/java/dev/vml/es/acm/core/osgi/OsgiContext.java
index 2e0f874b..99656472 100644
--- a/core/src/main/java/dev/vml/es/acm/core/osgi/OsgiContext.java
+++ b/core/src/main/java/dev/vml/es/acm/core/osgi/OsgiContext.java
@@ -2,7 +2,6 @@
 
 import com.day.cq.replication.Replicator;
 import dev.vml.es.acm.core.code.ExecutionQueue;
-import dev.vml.es.acm.core.script.ScriptScheduler;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.Optional;
@@ -55,10 +54,6 @@ public InstanceInfo getInstanceInfo() {
         return getService(InstanceInfo.class);
     }
 
-    public ScriptScheduler getScriptScheduler() {
-        return getService(ScriptScheduler.class);
-    }
-
     public ExecutionQueue getExecutionQueue() {
         return getService(ExecutionQueue.class);
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 0e58c9b9..912b0a61 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -28,8 +28,6 @@
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 
-import static dev.vml.es.acm.core.util.ServletResult.ok;
-
 @Component(
         service = ResourceChangeListener.class,
         immediate = true,
@@ -78,8 +76,7 @@ private void checkInstanceReady() {
             Repo repo = new Repo(resourceResolver);
             this.instanceReady = repo.isCompositeNodeStore();
         } catch (LoginException e) {
-            LOG.error("Cannot access repository while processing automatic script changes!", e);
-            this.instanceReady = false;
+            LOG.error("Cannot access repository while checking instance readiness!", e);
         }
     }
 
@@ -171,7 +168,7 @@ private void bootOrScheduleScripts() {
                         scheduleScript(script, (CronSchedule) schedule);
                     }
                 } else {
-                    LOG.error("Automatic script schedule cannot be determined! {}", scheduleResult.getExecution());
+                    LOG.error("Automatic script schedule cannot be determined: {}", scheduleResult.getExecution());
                 }
             });
         } catch (LoginException e) {
@@ -179,7 +176,7 @@ private void bootOrScheduleScripts() {
         }
     }
 
-    // TODO what if script changed and was booted
+    // TODO what if script changed and was booted, use checksum?
     private void bootScript(Script script) {
         AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
         if (!booted.get()) {
@@ -201,6 +198,7 @@ private void scheduleScript(Script script, CronSchedule schedule) {
         }
     }
 
+    // TODO check repo locks, check queue, check condition
     private boolean checkScript(Script script) {
         return false;
     }
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index 1ee5ae99..3f27ad32 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -1,18 +1,15 @@
 import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
-import { Key, Selection } from '@react-types/shared';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Magnify from '@spectrum-icons/workflow/Magnify';
-import React, { useState } from 'react';
+import React from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useFormatter } from '../hooks/formatter';
 import { useScripts } from '../hooks/script';
-import { InstanceRole, isExecutionNegative, ScriptType } from '../utils/api.types';
+import { isExecutionNegative, ScriptType } from '../utils/api.types';
 import DateExplained from './DateExplained';
 import ExecutionStatsBadge from './ExecutionStatsBadge';
 import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
-import ScriptSynchronizeButton from './ScriptSynchronizeButton';
-import ScriptToggleButton from './ScriptToggleButton';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
 
@@ -22,15 +19,7 @@ const ScriptAutomaticList: React.FC = () => {
   const navigate = useNavigate();
   const formatter = useFormatter();
 
-  const { scripts, loading, loadScripts } = useScripts(type);
-  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set<Key>());
-  const selectedIds = (selectedKeys: Selection): string[] => {
-    if (selectedKeys === 'all') {
-      return scripts?.list.map((script) => script.id) || [];
-    } else {
-      return Array.from(selectedKeys as Set<Key>).map((key) => key.toString());
-    }
-  };
+  const { scripts, loading } = useScripts(type);
 
   const renderEmptyState = () => (
     <IllustratedMessage>
@@ -53,8 +42,8 @@ const ScriptAutomaticList: React.FC = () => {
         <Flex direction="row" justifyContent="space-between" alignItems="center">
           <Flex flex="1" alignItems="center">
             <ButtonGroup>
-              <ScriptToggleButton type={type} selectedKeys={selectedIds(selectedKeys)} onToggle={loadScripts} />
-              {appState.instanceSettings.role == InstanceRole.AUTHOR && <ScriptSynchronizeButton selectedKeys={selectedIds(selectedKeys)} onSync={loadScripts} />}
+              &nbsp;
+              {/* TODO toggle scheduler button */}
             </ButtonGroup>
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
@@ -68,9 +57,7 @@ const ScriptAutomaticList: React.FC = () => {
       <TableView
         flex="1"
         aria-label={`Script list (${type})`}
-        selectionMode={'multiple'}
-        selectedKeys={selectedKeys}
-        onSelectionChange={setSelectedKeys}
+        selectionMode={'none'}
         renderEmptyState={renderEmptyState}
         onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
       >
diff --git a/ui.frontend/src/components/ScriptToggleButton.tsx b/ui.frontend/src/components/ScriptToggleButton.tsx
deleted file mode 100644
index eb4d8a6c..00000000
--- a/ui.frontend/src/components/ScriptToggleButton.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, Text } from '@adobe/react-spectrum';
-import Cancel from '@spectrum-icons/workflow/Cancel';
-import Checkmark from '@spectrum-icons/workflow/Checkmark';
-import React, { useState } from 'react';
-import { toastRequest } from '../utils/api';
-import { ScriptType } from '../utils/api.types';
-
-type ScriptToggleButtonProps = {
-  type: ScriptType.SCHEDULE | ScriptType.AUTOMATIC;
-  selectedKeys: string[];
-  onToggle: () => void;
-};
-
-const ScriptToggleButton: React.FC<ScriptToggleButtonProps> = ({ type, selectedKeys, onToggle }) => {
-  const [toggleDialogOpen, setToggleDialogOpen] = useState(false);
-  const [isLoading, setIsLoading] = useState(false);
-
-  const handleConfirm = async () => {
-    setIsLoading(true);
-    const action = type === ScriptType.SCHEDULE ? 'disable' : 'enable';
-    const ids = Array.from(selectedKeys);
-
-    const params = new URLSearchParams();
-    params.append('action', action);
-    params.append('type', type);
-    ids.forEach((id) => params.append('id', id));
-
-    try {
-      await toastRequest({
-        method: 'POST',
-        url: `/apps/acm/api/script.json?${params.toString()}`,
-        operation: `${action} scripts`,
-      });
-      onToggle();
-    } catch (error) {
-      console.error(`${action} scripts error:`, error);
-    } finally {
-      setIsLoading(false);
-      setToggleDialogOpen(false);
-    }
-  };
-
-  const renderToggleDialog = () => (
-    <>
-      <Heading>
-        <Text>Confirmation</Text>
-      </Heading>
-      <Divider />
-      <Content>
-        {type === ScriptType.SCHEDULE ? (
-          <Text>Disabling scripts will stop their automatic execution. To execute them again, you need to enable them or reinstall the package with scripts.</Text>
-        ) : (
-          <Text>Enabling scripts can cause changes in the repository and potential data loss. Ensure the script is ready to use. It is recommended to provide enabled scripts via a package, not manually.</Text>
-        )}
-      </Content>
-      <ButtonGroup>
-        <Button variant="secondary" onPress={() => setToggleDialogOpen(false)} isDisabled={isLoading}>
-          <Cancel />
-          <Text>Cancel</Text>
-        </Button>
-        <Button variant="negative" style="fill" onPress={handleConfirm} isPending={isLoading}>
-          <Checkmark />
-          <Text>Confirm</Text>
-        </Button>
-      </ButtonGroup>
-    </>
-  );
-
-  return (
-    <DialogTrigger isOpen={toggleDialogOpen} onOpenChange={setToggleDialogOpen}>
-      <Button variant={type === ScriptType.SCHEDULE ? 'negative' : 'accent'} style="fill" isDisabled={selectedKeys.length === 0} onPress={() => setToggleDialogOpen(true)}>
-        {type === ScriptType.SCHEDULE ? <Cancel /> : <Checkmark />}
-        <Text>{type === ScriptType.SCHEDULE ? 'Disable' : 'Enable'}</Text>
-      </Button>
-      <Dialog>{renderToggleDialog()}</Dialog>
-    </DialogTrigger>
-  );
-};
-
-export default ScriptToggleButton;

From add4d51d23b4c4d9fa729443a628d5627aea4abf Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 08:29:43 +0200
Subject: [PATCH 18/83] Safe script execution

---
 README.md                                     |  4 -
 .../vml/es/acm/core/code/ExecutionQueue.java  |  7 ++
 .../dev/vml/es/acm/core/code/Executor.java    | 44 ++++++----
 .../core/script/AutomaticScriptScheduler.java | 85 +++++++++++++------
 .../automatic/example/ACME-100_acl.groovy     |  4 +-
 .../example/ACME-21_every-hour.groovy         |  2 +-
 .../example/ACME-22_every-day.groovy          |  6 +-
 .../example/ACME-23_every-week.groovy         |  6 +-
 .../example/ACME-24_every-month.groovy        |  6 +-
 .../snippet/available/core/condition/date.yml | 23 -----
 .../available/core/condition/every_day.yml    | 15 ----
 .../available/core/condition/every_hour.yml   | 15 ----
 .../available/core/condition/every_month.yml  | 16 ----
 .../available/core/condition/every_week.yml   | 15 ----
 .../available/core/condition/every_year.yml   | 15 ----
 .../available/core/condition/not_running.yml  |  9 --
 .../core/condition/not_running_self.yml       |  9 --
 .../snippet/available/core/repo/dry_run.yml   |  4 -
 .../src/components/ScriptAutomaticList.tsx    |  5 +-
 19 files changed, 114 insertions(+), 176 deletions(-)
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/date.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_day.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_hour.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_month.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_week.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_year.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running.yml
 delete mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running_self.yml

diff --git a/README.md b/README.md
index 06bbdb44..70c325db 100644
--- a/README.md
+++ b/README.md
@@ -300,10 +300,6 @@ void describeRun() {
     arguments.bool("clean") { value = true; switcher(); description = "Finally delete all created resources" }
 }
 
-boolean canRun() {
-    return conditions.notRunningSelf()
-}
-
 void doRun() {
     repo.dryRun(arguments.value("dryRun")) {
         log.info "Creating a folder structure in the temporary directory of the repository."
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index b6776f0f..7f0d3e89 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -90,6 +90,13 @@ public Stream<Execution> findAll() {
         return findJobs().map(job -> new QueuedExecution(executor, job));
     }
 
+    public Optional<Execution> findByExecutableId(String executableId) {
+        if (StringUtils.isBlank(executableId)) {
+            return Optional.empty();
+        }
+        return findAll().filter(e -> StringUtils.equals(e.getExecutable().getId(), executableId)).findFirst();
+    }
+
     public Stream<ExecutionSummary> findAllSummaries() {
         return findJobs().map(job -> new QueuedExecutionSummary(executor, job));
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 8060f169..05a0e019 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -20,6 +20,8 @@
 @Designate(ocd = Executor.Config.class)
 public class Executor {
 
+    public static final String LOCK_NAME = "executor";
+
     @ObjectClassDefinition(name = "AEM Content Manager - Code Executor")
     public @interface Config {
 
@@ -32,12 +34,6 @@ public class Executor {
                         "Enables debug mode for troubleshooting. Changed behaviors include: start saving skipped executions in history.")
         boolean debug() default false;
 
-        @AttributeDefinition(
-                name = "Locking",
-                description =
-                        "Prevents concurrent execution of the same executable. Especially useful for scripts running on clustered author instances on AEMaaCS. Use together with idle and not running conditions in the script.")
-        boolean locking() default true;
-
         @AttributeDefinition(
                 name = "Log Printing Enabled",
                 description =
@@ -127,10 +123,12 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
             }
 
+            if (context.getCodeContext().getLocker().isLocked(executableLockName(context))) {
+                return execution.end(ExecutionStatus.SKIPPED);
+            }
+
             try {
-                if (config.locking()) {
-                    context.getCodeContext().getLocker().lock(executableLockName(context));
-                }
+                context.getCodeContext().getLocker().lock(executableLockName(context));
                 statuses.put(context.getId(), ExecutionStatus.RUNNING);
                 if (config.logPrintingEnabled()) {
                     context.getOut().fromSelfLogger();
@@ -139,9 +137,7 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 contentScript.run();
                 return execution.end(ExecutionStatus.SUCCEEDED);
             } finally {
-                if (config.locking()) {
-                    context.getCodeContext().getLocker().unlock(executableLockName(context));
-                }
+                context.getCodeContext().getLocker().unlock(executableLockName(context));
             }
         } catch (Throwable e) {
             execution.error(e);
@@ -155,7 +151,7 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
     }
 
     private String executableLockName(ExecutionContext context) {
-        return context.getExecutable().getId();
+        return String.format("%s/%s", context.getExecutable().getId(), LOCK_NAME);
     }
 
     public Optional<ExecutionStatus> checkStatus(String executionId) {
@@ -176,6 +172,24 @@ public Description describe(ExecutionContext context) {
         }
     }
 
+    public Execution check(ExecutionContext context) throws AcmException {
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+
+        try {
+            ContentScript contentScript = new ContentScript(context);
+            execution.start();
+            boolean canRun = contentScript.canRun();
+            if (canRun) {
+                return execution.end(ExecutionStatus.SUCCEEDED);
+            } else  {
+                return execution.end(ExecutionStatus.SKIPPED);
+            }
+        } catch (Throwable e) {
+            execution.error(e);
+            return execution.end(ExecutionStatus.FAILED);
+        }
+    }
+
     public ScheduleResult schedule(ExecutionContext context) {
         ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
         try {
@@ -197,7 +211,7 @@ public boolean isHistory() {
         return config.history();
     }
 
-    public boolean isLocking() {
-        return config.locking();
+    public boolean isLocked(ExecutionContext context) {
+        return context.getCodeContext().getLocker().isLocked(executableLockName(context));
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 912b0a61..bf2bc545 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -42,9 +42,13 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
 
+    private static final long INSTANCE_HEALTHY_INTERVAL = 1000;
+
+    private static final long INSTANCE_HEALTHY_RETRIES = 60 * 30; // 30 minutes
+
     private static final String BOOT_JOB_NAME = "boot";
 
-    private boolean instanceReady = false;
+    private Boolean instanceReady;
 
     private final Map<String, AtomicBoolean> booted = new ConcurrentHashMap<>();
 
@@ -67,25 +71,16 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     @Activate
     protected void activate() {
-        checkInstanceReady();
-        bootWhenInstanceUp();
-    }
-
-    private void checkInstanceReady() {
-        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-            Repo repo = new Repo(resourceResolver);
-            this.instanceReady = repo.isCompositeNodeStore();
-        } catch (LoginException e) {
-            LOG.error("Cannot access repository while checking instance readiness!", e);
+        if (checkInstanceReady()) {
+            bootWhenInstanceUp();
         }
     }
 
     @Override
     public void onChange(List<ResourceChange> changes) {
-        if (!instanceReady || changes.isEmpty()) {
-            return;
+        if (!changes.isEmpty() && checkInstanceReady()) {
+            bootWhenScriptsChanged();
         }
-        bootWhenScriptsChanged();
     }
 
     private void bootWhenInstanceUp() {
@@ -121,27 +116,47 @@ private ScheduleResult determineSchedule(Script script, ResourceResolver resourc
     private Job bootJob() {
         return context -> {
             unscheduleScripts();
-            awaitInstanceHealthy();
-            bootOrScheduleScripts();
+            if (awaitInstanceHealthy()) {
+                bootOrScheduleScripts();
+            }
         };
     }
 
-    private void awaitInstanceHealthy() {
+    private boolean checkInstanceReady() {
+        if (instanceReady == null) {
+            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+                Repo repo = new Repo(resourceResolver);
+                instanceReady = repo.isCompositeNodeStore();
+            } catch (LoginException e) {
+                LOG.error("Cannot access repository while checking instance readiness!", e);
+            }
+        }
+        return instanceReady;
+    }
+
+    private boolean awaitInstanceHealthy() {
         HealthStatus healthStatus = null;
+        long retries = 0;
         while (healthStatus == null || !healthStatus.isHealthy()) {
+            if (retries >= INSTANCE_HEALTHY_RETRIES) {
+                LOG.error("Automatic scripts booting failed after {} retries due to health status: {}", INSTANCE_HEALTHY_RETRIES, healthStatus);
+                return false;
+            }
             healthStatus = healthChecker.checkStatus();
             if (healthStatus.isHealthy()) {
                 break;
             } else {
                 LOG.warn("Automatic scripts booting paused due to health status: {}", healthStatus);
                 try {
-                    Thread.sleep(1000); // wait before retrying
+                    Thread.sleep(INSTANCE_HEALTHY_INTERVAL); // wait before retrying
+                    retries++;
                 } catch (InterruptedException e) {
                     Thread.currentThread().interrupt();
                     LOG.error("Automatic scripts booting interrupted!", e);
                 }
             }
         }
+        return true;
     }
 
     private void unscheduleScripts() {
@@ -163,7 +178,7 @@ private void bootOrScheduleScripts() {
                 if (scheduleResult.getExecution().getStatus() == ExecutionStatus.SUCCEEDED) {
                     Schedule schedule = scheduleResult.getSchedule();
                     if (schedule instanceof BootSchedule) {
-                        bootScript(script);
+                        bootScript(script, resourceResolver);
                     } else if (schedule instanceof CronSchedule) {
                         scheduleScript(script, (CronSchedule) schedule);
                     }
@@ -177,14 +192,14 @@ private void bootOrScheduleScripts() {
     }
 
     // TODO what if script changed and was booted, use checksum?
-    private void bootScript(Script script) {
+    private void bootScript(Script script, ResourceResolver resourceResolver) {
         AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
         if (!booted.get()) {
-            if (!checkScript(script)) {
-                LOG.info("Boot script '{}' is not ready for queueing!", script.getPath());
-            } else {
+            if (checkScript(script, resourceResolver)) {
                 queueScript(script);
                 booted.set(true);
+            } else {
+                LOG.info("Boot script '{}' is not ready for queueing!", script.getPath());
             }
         }
     }
@@ -198,9 +213,25 @@ private void scheduleScript(Script script, CronSchedule schedule) {
         }
     }
 
-    // TODO check repo locks, check queue, check condition
-    private boolean checkScript(Script script) {
-        return false;
+    private boolean checkScript(Script script, ResourceResolver resourceResolver) {
+        try (ExecutionContext context = executor.createContext(
+                ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
+            if (executor.isLocked(context)) {
+                LOG.debug("Script '{}' is already locked!", script.getPath());
+                return false;
+            }
+            Execution executionPending = executionQueue.findByExecutableId(context.getExecutable().getId()).orElse(null);
+            if (executionPending != null) {
+                LOG.debug("Script '{}' is already queued: {}", script.getPath(), executionPending);
+                return false;
+            }
+            Execution executionChecking = executor.check(context);
+            if (executionChecking.getStatus() != ExecutionStatus.SUCCEEDED) {
+                LOG.debug("Script '{}' cannot run: {}", script.getPath(), executionChecking);
+                return false;
+            }
+            return true;
+        }
     }
 
     private void queueScript(Script script) {
@@ -215,7 +246,7 @@ private Job cronJob(String scriptPath) {
                 if (script == null) {
                     LOG.error("Cron schedule script '{}' not found in repository!", scriptPath);
                 } else {
-                    if (checkScript(script)) {
+                    if (checkScript(script, resourceResolver)) {
                         queueScript(script);
                     } else {
                         LOG.warn("Cron schedule script '{}' is not ready for queueing!", scriptPath);
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
index b46d67ae..468e2b0b 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
@@ -1,5 +1,5 @@
-boolean canRun() {
-    return conditions.notRunning() && conditions.everyDayStart()
+def scheduleRun() {
+    return schedules.cron("0 10 * ? * * *") // every hour at minute 10
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
index 6c7aa881..298ae372 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
@@ -1,5 +1,5 @@
 def scheduleRun() {
-    return schedules.cron("0 0 * ? * * *")
+    return schedules.cron("0 5 * ? * * *") // every hour at minute 5
 }
 
 boolean canRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
index 8ff235af..a47baf00 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
@@ -1,5 +1,9 @@
+def scheduleRun() {
+    return schedules.cron("0 0 8 ? * * *") // at 08:00 every day
+}
+
 boolean canRun() {
-    return conditions.everyDayAt("07:45")
+    return conditions.always()
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
index 94b5cdab..7ce2a01d 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
@@ -1,5 +1,9 @@
+def scheduleRun() {
+    return schedules.cron("0 48 7 ? * MON *") // at 07:48 every Monday
+}
+
 boolean canRun() {
-    return conditions.everyWeekAt("Monday", "07:48")
+    return conditions.always()
 }
 
 void doRun() {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
index 99243909..fd7f89ee 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
@@ -1,5 +1,9 @@
+def scheduleRun() {
+    return schedules.cron("0 49 7 21 * ? *") // at 07:49 on the 21st day of every month
+}
+
 boolean canRun() {
-    return conditions.everyMonthAt(21, "07:49")
+    return conditions.always()
 }
 
 void doRun() {
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/date.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/date.yml
deleted file mode 100644
index adabf7c3..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/date.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-group: Condition
-name: condition_is_date
-content: |
-  conditions.isDate(${1:date})
-documentation: |
-  Execute the script on a specific date.
-
-  The date can be provided as a string in one of the following formats:
-  
-    - **yyyy-MM-dd HH:mm:ss**: `conditions.isDate("2025-04-16 12:30:00")`
-    - **yyyy-MM-dd'T'HH:mm:ss**: `conditions.isDate("2025-04-16T12:30:00")`
-    - **yyyy-MM-dd'T'HH:mm:ss.SSSXXX**: `conditions.isDate("2025-04-16T12:30:00.123+02:00")`
-    - **yyyy-MM-dd'T'HH:mm:ssXXX**: `conditions.isDate("2025-04-16T12:30:00+02:00")`
-    
-    If the date string does not include timezone information, the server assumes the date is in its default timezone. In such cases, the date is adjusted to match the server's timezone if there is a discrepancy.
-  
-  Alternatively, you can provide a `ZonedDateTime` or `LocalDateTime` object, such as:
-    `conditions.isDate(ZonedDateTime.of(2025, 12, 25, 10, 0, 0, 0, ZoneId.of("Europe/Warsaw")))`.
-    
-    Using `ZonedDateTime` is recommended for precise timezone handling, as it avoids ambiguity and ensures accurate date interpretation.
-    
-    **Note:** The script scheduler operates at regular intervals (default: 30 seconds). The provided date must be in the future and fall within the range of the current time and the next scheduler interval.
-    For example, if the current time is `12:00:00` and the scheduler runs every 30 seconds, the date must be between `12:00:00` and `12:00:30` (inclusive) to meet the condition.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_day.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_day.yml
deleted file mode 100644
index 72bef305..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_day.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-group: Condition
-name: condition_every_day
-content: |
-  conditions.everyDay()
-documentation: |
-  Execute the script only once a day.
-  
-  Optionally, you can specify a time of day when the script should run. If no time is specified, it defaults to midnight (00:00).
-  
-  For example:
-  ```groovy
-  conditions.everyDayInRange()
-  conditions.everyDayStart()
-  conditions.everyDayAt("2:00")
-  ```
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_hour.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_hour.yml
deleted file mode 100644
index dd07151f..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_hour.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-group: Condition
-name: condition_every_hour
-content: |
-  conditions.everyHour()
-documentation: |
-  Execute the script only once an hour.
-  
-  Optionally, you can specify a specific minute when the script should run. If no minute is specified, it defaults to the start of the hour (00 minutes).
-  
-  For example:
-  ```groovy
-  conditions.everyHourInRange()
-  conditions.everyHourStart()
-  conditions.everyHourAt(30)
-  ```
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_month.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_month.yml
deleted file mode 100644
index b4ceb54c..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_month.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-group: Condition
-name: condition_every_month
-content: |
-  conditions.everyMonth()
-documentation: |
-  Execute the script only once a month.
-  
-  Optionally, you can specify a specific day of the month and/or a time of day when the script should run. If no day is specified, it defaults to the first day of the month. If no time is specified, it defaults to midnight (00:00).
-  
-  For example:
-  ```groovy
-  conditions.everyMonthInRange()
-  conditions.everyMonthStart()
-  conditions.everyMonthAt(15)
-  conditions.everyMonthAt(3, "02:00")
-  ```
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_week.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_week.yml
deleted file mode 100644
index 7b408ecb..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_week.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-group: Condition
-name: condition_every_week
-content: |
-  conditions.everyWeek()
-documentation: |
-  Execute the script only once a week.
-  
-  Optionally, you can specify a specific day of the week and/or a time of day when the script should run. If no day is specified, it defaults to the first day of the week (Monday). If no time is specified, it defaults to midnight (00:00).
-  
-  For example:
-  ```groovy
-  conditions.everyWeekInRange()
-  conditions.everyWeekStart()
-  conditions.everyWeekAt("Wednesday", "14:00")
-  ```
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_year.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_year.yml
deleted file mode 100644
index b1dde2d9..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/every_year.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-group: Condition
-name: condition_every_year
-content: |
-  conditions.everyYear()
-documentation: |
-  Execute the script only once a year.
-  
-  Optionally, you can specify a specific month, day of the month, and/or a time of day when the script should run. If no month is specified, it defaults to January. If no day is specified, it defaults to the first day of the month. If no time is specified, it defaults to midnight (00:00).
-
-  For example:
-  ```groovy
-  conditions.everyYearInRange()
-  conditions.everyYearStart()
-  conditions.everyYearAt("June", 15, "14:00")
-  ```
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running.yml
deleted file mode 100644
index 707bb38b..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-group: Condition
-name: condition_not_running
-content: |
-  conditions.notRunning()
-documentation: |
-  Execute the script only if none of the scripts are running.
-  
-  Ensures that the script is not executed in parallel (including other scripts).
-  This condition guarantees that on cluster nodes only one instance of any script is running at a time.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running_self.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running_self.yml
deleted file mode 100644
index d05901ae..00000000
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/not_running_self.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-group: Condition
-name: condition_not_running_self
-content: |
-  conditions.notRunningSelf()
-documentation: |
-  Execute the script only if it is not already running.
-  
-  Ensures that the same script is not executed in parallel (but other scripts may be).
-  This condition guarantees that on cluster nodes only one instance of this script is running at a time.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/dry_run.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/dry_run.yml
index dc2f7d6c..d6d3c23b 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/dry_run.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/repo/dry_run.yml
@@ -17,10 +17,6 @@ documentation: |
   void describeRun() {
     arguments.bool("dryRun") { value = true; switcher(); description = "Do not commit changes to the repository" }
   }
-  
-  boolean canRun() {
-    return conditions.notRunning()
-  }
     
   void doRun() { 
     repo.dryRun(arguments.value("dryRun")) {
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index 3f27ad32..79009452 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -14,12 +14,11 @@ import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
 
 const ScriptAutomaticList: React.FC = () => {
-  const type = ScriptType.AUTOMATIC;
   const appState = useAppState();
   const navigate = useNavigate();
   const formatter = useFormatter();
 
-  const { scripts, loading } = useScripts(type);
+  const { scripts, loading } = useScripts(ScriptType.AUTOMATIC);
 
   const renderEmptyState = () => (
     <IllustratedMessage>
@@ -56,7 +55,7 @@ const ScriptAutomaticList: React.FC = () => {
       </View>
       <TableView
         flex="1"
-        aria-label={`Script list (${type})`}
+        aria-label={`Script list (${ScriptType.AUTOMATIC.toLowerCase()})`}
         selectionMode={'none'}
         renderEmptyState={renderEmptyState}
         onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}

From 43e5dcd0b7ba26870e81dc7dadb525a550ae3647 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 10:00:34 +0200
Subject: [PATCH 19/83] Safe script execution

---
 .../vml/es/acm/core/code/ExecutableUtils.java |  3 +-
 .../vml/es/acm/core/code/ExecutionQueue.java  |  4 +-
 .../dev/vml/es/acm/core/code/Executor.java    |  3 +-
 .../es/acm/core/code/ImmediateExecution.java  |  2 +
 .../acm/core/code/schedule/CronSchedule.java  |  2 +-
 .../acm/core/code/script/ContentScript.java   | 12 +++-
 .../es/acm/core/code/script/ScriptUtils.java  | 10 ++-
 .../vml/es/acm/core/repo/RepoResource.java    |  3 +-
 .../core/script/AutomaticScriptScheduler.java | 66 +++++++++++--------
 .../es/acm/core/script/ScriptRepository.java  |  1 -
 .../vml/es/acm/core/util/ResourceUtils.java   | 12 ++--
 11 files changed, 72 insertions(+), 46 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
index cf2afa00..cc23e2a3 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
@@ -16,7 +16,8 @@ public static String nameById(String id) {
             return "Console";
         }
         if (StringUtils.startsWith(id, ScriptType.AUTOMATIC.root() + "/")) {
-            return String.format("Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.AUTOMATIC.root() + "/"));
+            return String.format(
+                    "Automatic Script '%s'", StringUtils.removeStart(id, ScriptType.AUTOMATIC.root() + "/"));
         }
         if (StringUtils.startsWith(id, ScriptType.MANUAL.root() + "/")) {
             return String.format("Manual Script '%s'", StringUtils.removeStart(id, ScriptType.MANUAL.root() + "/"));
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 7f0d3e89..01addebc 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -94,7 +94,9 @@ public Optional<Execution> findByExecutableId(String executableId) {
         if (StringUtils.isBlank(executableId)) {
             return Optional.empty();
         }
-        return findAll().filter(e -> StringUtils.equals(e.getExecutable().getId(), executableId)).findFirst();
+        return findAll()
+                .filter(e -> StringUtils.equals(e.getExecutable().getId(), executableId))
+                .findFirst();
     }
 
     public Stream<ExecutionSummary> findAllSummaries() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 05a0e019..49b460b2 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -1,7 +1,6 @@
 package dev.vml.es.acm.core.code;
 
 import dev.vml.es.acm.core.AcmException;
-import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import dev.vml.es.acm.core.code.script.ContentScript;
 import dev.vml.es.acm.core.osgi.OsgiContext;
 import dev.vml.es.acm.core.util.ResourceUtils;
@@ -181,7 +180,7 @@ public Execution check(ExecutionContext context) throws AcmException {
             boolean canRun = contentScript.canRun();
             if (canRun) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
-            } else  {
+            } else {
                 return execution.end(ExecutionStatus.SKIPPED);
             }
         } catch (Throwable e) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java b/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java
index 0d3bded9..3af8f905 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ImmediateExecution.java
@@ -4,6 +4,7 @@
 import dev.vml.es.acm.core.util.ExceptionUtils;
 import java.io.InputStream;
 import java.util.Date;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 
@@ -109,6 +110,7 @@ public String toString() {
                 .append("executable", getExecutable())
                 .append("status", getStatus())
                 .append("duration", getDuration())
+                .append("error", StringUtils.abbreviate(error, 200))
                 .toString();
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java b/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
index c9cb6ad3..ed5ef7d6 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/schedule/CronSchedule.java
@@ -7,7 +7,7 @@ public class CronSchedule implements Schedule {
     private final String expression;
 
     public CronSchedule(String expression) {
-       this.expression = expression;
+        this.expression = expression;
     }
 
     @Override
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
index 4f157a74..f2c881fe 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ContentScript.java
@@ -33,7 +33,11 @@ public Schedule schedule() {
         } catch (MissingMethodException e) {
             return new BootSchedule(); // intentional default schedule
         } catch (Throwable e) {
-            throw new AcmException(String.format("Executable '%s' schedule method error!", executionContext.getExecutable().getId()), e);
+            throw new AcmException(
+                    String.format(
+                            "Executable '%s' schedule method error!",
+                            executionContext.getExecutable().getId()),
+                    e);
         }
     }
 
@@ -43,7 +47,11 @@ public void describe() {
         } catch (MissingMethodException e) {
             // ignore as the method is optional
         } catch (Throwable e) {
-            throw new AcmException(String.format("Executable '%s' describe method error!", executionContext.getExecutable().getId()), e);
+            throw new AcmException(
+                    String.format(
+                            "Executable '%s' describe method error!",
+                            executionContext.getExecutable().getId()),
+                    e);
         }
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
index 99c09f36..c2c32642 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
@@ -13,6 +13,8 @@
 
 public final class ScriptUtils {
 
+    private static final String DEF_TYPE = "java.lang.Object";
+
     private ScriptUtils() {
         // intentionally empty
     }
@@ -35,9 +37,11 @@ public static boolean hasMethod(ClassNode mainClass, String methodName) {
 
     public static boolean isMethodValid(ClassNode mainClass, String methodName, String returnType, int paramCount) {
         for (MethodNode method : mainClass.getMethods()) {
-            if (methodName.equals(method.getName())
-                    && returnType.equals(method.getReturnType().getName())
-                    && method.getParameters().length == paramCount) {
+            String actualReturnType = method.getReturnType().getName();
+            if (DEF_TYPE.equals(actualReturnType)
+                    || methodName.equals(method.getName())
+                            && returnType.equals(actualReturnType)
+                            && method.getParameters().length == paramCount) {
                 return true;
             }
         }
diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
index a243e4dd..d7a63c15 100644
--- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
+++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
@@ -98,7 +98,8 @@ public RepoResource ensure(String resourceType) {
         Resource resource = resolve();
         if (resource == null) {
             try {
-                ResourceUtil.getOrCreateResource(repo.getResourceResolver(), path, Collections.emptyMap(), resourceType, repo.isAutoCommit());
+                ResourceUtil.getOrCreateResource(
+                        repo.getResourceResolver(), path, Collections.emptyMap(), resourceType, repo.isAutoCommit());
             } catch (PersistenceException e) {
                 throw new RepoException(
                         String.format("Cannot ensure resource '%s' at path '%s'!", resourceType, path), e);
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index bf2bc545..d6b0822f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -1,12 +1,19 @@
 package dev.vml.es.acm.core.script;
 
 import dev.vml.es.acm.core.code.*;
+import dev.vml.es.acm.core.code.schedule.BootSchedule;
+import dev.vml.es.acm.core.code.schedule.CronSchedule;
 import dev.vml.es.acm.core.instance.HealthChecker;
 import dev.vml.es.acm.core.instance.HealthStatus;
+import dev.vml.es.acm.core.osgi.InstanceInfo;
+import dev.vml.es.acm.core.osgi.InstanceType;
 import dev.vml.es.acm.core.repo.Repo;
-import dev.vml.es.acm.core.code.schedule.BootSchedule;
-import dev.vml.es.acm.core.code.schedule.CronSchedule;
 import dev.vml.es.acm.core.util.ResourceUtils;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -22,22 +29,15 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.atomic.AtomicBoolean;
-
 @Component(
         service = ResourceChangeListener.class,
         immediate = true,
         property = {
-                ResourceChangeListener.PATHS + "=glob:"+ScriptRepository.ROOT+"/automatic/**/*.groovy",
-                ResourceChangeListener.CHANGES + "=ADDED",
-                ResourceChangeListener.CHANGES + "=CHANGED",
-                ResourceChangeListener.CHANGES + "=REMOVED"
-        }
-)
+            ResourceChangeListener.PATHS + "=glob:" + ScriptRepository.ROOT + "/automatic/**/*.groovy",
+            ResourceChangeListener.CHANGES + "=ADDED",
+            ResourceChangeListener.CHANGES + "=CHANGED",
+            ResourceChangeListener.CHANGES + "=REMOVED"
+        })
 public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
@@ -57,6 +57,9 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     @Reference
     private ResourceResolverFactory resourceResolverFactory;
 
+    @Reference
+    private InstanceInfo instanceInfo;
+
     @Reference
     private Executor executor;
 
@@ -107,8 +110,8 @@ private ScheduleOptions configureScheduleOptions(String name, ScheduleOptions op
     }
 
     private ScheduleResult determineSchedule(Script script, ResourceResolver resourceResolver) {
-        try (ExecutionContext context = executor.createContext(
-                ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
+        try (ExecutionContext context =
+                executor.createContext(ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
             return executor.schedule(context);
         }
     }
@@ -124,11 +127,15 @@ private Job bootJob() {
 
     private boolean checkInstanceReady() {
         if (instanceReady == null) {
-            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-                Repo repo = new Repo(resourceResolver);
-                instanceReady = repo.isCompositeNodeStore();
-            } catch (LoginException e) {
-                LOG.error("Cannot access repository while checking instance readiness!", e);
+            if (InstanceType.CLOUD_CONTAINER.equals(instanceInfo.getType())) {
+                try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+                    Repo repo = new Repo(resourceResolver);
+                    instanceReady = repo.isCompositeNodeStore();
+                } catch (LoginException e) {
+                    LOG.error("Cannot access repository while checking instance readiness!", e);
+                }
+            } else {
+                instanceReady = true;
             }
         }
         return instanceReady;
@@ -139,7 +146,10 @@ private boolean awaitInstanceHealthy() {
         long retries = 0;
         while (healthStatus == null || !healthStatus.isHealthy()) {
             if (retries >= INSTANCE_HEALTHY_RETRIES) {
-                LOG.error("Automatic scripts booting failed after {} retries due to health status: {}", INSTANCE_HEALTHY_RETRIES, healthStatus);
+                LOG.error(
+                        "Automatic scripts booting failed after {} retries due to health status: {}",
+                        INSTANCE_HEALTHY_RETRIES,
+                        healthStatus);
                 return false;
             }
             healthStatus = healthChecker.checkStatus();
@@ -206,7 +216,9 @@ private void bootScript(Script script, ResourceResolver resourceResolver) {
 
     private void scheduleScript(Script script, CronSchedule schedule) {
         if (StringUtils.isNotBlank(schedule.getExpression())) {
-            scheduler.schedule(cronJob(script.getPath()), configureScheduleOptions(script.getPath(), scheduler.EXPR(schedule.getExpression())));
+            scheduler.schedule(
+                    cronJob(script.getPath()),
+                    configureScheduleOptions(script.getPath(), scheduler.EXPR(schedule.getExpression())));
             scheduled.add(script.getPath());
         } else {
             LOG.error("Cron schedule for script '{}' has no cron expression defined!", script.getPath());
@@ -214,13 +226,15 @@ private void scheduleScript(Script script, CronSchedule schedule) {
     }
 
     private boolean checkScript(Script script, ResourceResolver resourceResolver) {
-        try (ExecutionContext context = executor.createContext(
-                ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
+        try (ExecutionContext context =
+                executor.createContext(ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
             if (executor.isLocked(context)) {
                 LOG.debug("Script '{}' is already locked!", script.getPath());
                 return false;
             }
-            Execution executionPending = executionQueue.findByExecutableId(context.getExecutable().getId()).orElse(null);
+            Execution executionPending = executionQueue
+                    .findByExecutableId(context.getExecutable().getId())
+                    .orElse(null);
             if (executionPending != null) {
                 LOG.debug("Script '{}' is already queued: {}", script.getPath(), executionPending);
                 return false;
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index e3c05e6e..2dbc005b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -7,7 +7,6 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
-
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.jcr.resource.api.JcrResourceConstants;
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index ea82771b..7651949f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -1,7 +1,6 @@
 package dev.vml.es.acm.core.util;
 
 import dev.vml.es.acm.core.AcmException;
-
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -106,14 +105,11 @@ public static void move(ResourceResolver resolver, String sourcePath, String tar
         }
     }
 
-    public static Resource makeFolders(ResourceResolver resourceResolver, String path, String resourceType) throws AcmException {
+    // TODO Value for key sling:resourceType can't be put into node: nt:folder
+    public static Resource makeFolders(ResourceResolver resourceResolver, String path, String resourceType)
+            throws AcmException {
         try {
-            return ResourceUtil.getOrCreateResource(
-                    resourceResolver,
-                    path,
-                    Collections.emptyMap(),
-                    resourceType,
-                    true);
+            return ResourceUtil.getOrCreateResource(resourceResolver, path, Collections.emptyMap(), resourceType, true);
         } catch (Exception e) {
             throw new AcmException(String.format("Folders cannot be created for path '%s'", path), e);
         }

From 53041ed0437d8d0c35b66a715f2afc20d3db5d28 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 10:36:59 +0200
Subject: [PATCH 20/83] Safe script execution

---
 .../java/dev/vml/es/acm/core/code/ExecutionHistory.java    | 4 +++-
 core/src/main/java/dev/vml/es/acm/core/code/Locker.java    | 5 +++--
 .../main/java/dev/vml/es/acm/core/repo/RepoResource.java   | 1 +
 .../vml/es/acm/core/script/AutomaticScriptScheduler.java   | 7 +++++++
 .../main/java/dev/vml/es/acm/core/util/ResourceUtils.java  | 1 +
 ui.frontend/src/components/ExecutableIdValue.tsx           | 4 ++--
 ui.frontend/src/utils/api.types.ts                         | 1 -
 7 files changed, 17 insertions(+), 6 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
index 7ed707e9..421a4481 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
@@ -14,6 +14,7 @@
 import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class ExecutionHistory {
 
@@ -29,13 +30,14 @@ public Stream<Execution> read() {
         return Stream.empty();
     }
 
+    // TODO do not save 'sling:resourceType' = 'sling:Folder' here (avoid corrupting indexes when many data is saved)
     public void save(ExecutionContext context, ImmediateExecution execution) throws AcmException {
         Resource root = getOrCreateRoot();
         Map<String, Object> props = HistoricalExecution.toMap(context, execution);
 
         try {
             String dirPath = root.getPath() + "/" + StringUtils.substringBeforeLast(execution.getId(), "/");
-            Resource dir = ResourceUtils.makeFolders(resourceResolver, dirPath, JcrConstants.NT_FOLDER);
+            Resource dir = ResourceUtils.makeFolders(resourceResolver, dirPath, JcrResourceConstants.NT_SLING_FOLDER);
             String entryName = StringUtils.substringAfterLast(execution.getId(), "/");
             resourceResolver.create(dir, entryName, props);
             resourceResolver.commit();
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
index 2726d8aa..00c37231 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
@@ -38,6 +38,7 @@ public boolean isLocked(String lockName) {
         return isLock(lock);
     }
 
+    // TODO do not save 'sling:resourceType'
     public void lock(String lockName) {
         String name = normalizeName(lockName);
         Resource lock = getLock(name);
@@ -51,9 +52,9 @@ public void lock(String lockName) {
             if (name.contains("/")) {
                 String dirPath = StringUtils.substringBeforeLast(name, "/");
                 nodeName = StringUtils.substringAfterLast(name, "/");
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT + "/" + dirPath, JcrConstants.NT_FOLDER);
+                dirResource = ResourceUtils.makeFolders(resolver, ROOT + "/" + dirPath, JcrConstants.NT_UNSTRUCTURED);
             } else {
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT, JcrConstants.NT_FOLDER);
+                dirResource = ResourceUtils.makeFolders(resolver, ROOT, JcrConstants.NT_UNSTRUCTURED);
                 nodeName = name;
             }
             Map<String, Object> props = new HashMap<>();
diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
index d7a63c15..745f52c0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
+++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
@@ -82,6 +82,7 @@ public boolean exists() {
         return get().isPresent();
     }
 
+    // TODO does not work
     public RepoResource ensureFolder() {
         return ensure(JcrConstants.NT_FOLDER);
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index d6b0822f..9a1ac0f2 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -25,6 +25,7 @@
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -79,6 +80,11 @@ protected void activate() {
         }
     }
 
+    @Deactivate
+    protected void deactivate() {
+        unscheduleScripts();
+    }
+
     @Override
     public void onChange(List<ResourceChange> changes) {
         if (!changes.isEmpty() && checkInstanceReady()) {
@@ -170,6 +176,7 @@ private boolean awaitInstanceHealthy() {
     }
 
     private void unscheduleScripts() {
+        scheduler.unschedule(BOOT_JOB_NAME);
         for (String scriptPath : scheduled) {
             try {
                 scheduler.unschedule(scriptPath);
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index 7651949f..72c3dee0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -106,6 +106,7 @@ public static void move(ResourceResolver resolver, String sourcePath, String tar
     }
 
     // TODO Value for key sling:resourceType can't be put into node: nt:folder
+    // TODO this method adds extra 'sling:resourceType' property, which is not needed for folders
     public static Resource makeFolders(ResourceResolver resourceResolver, String path, String resourceType)
             throws AcmException {
         try {
diff --git a/ui.frontend/src/components/ExecutableIdValue.tsx b/ui.frontend/src/components/ExecutableIdValue.tsx
index f6940a2b..850b3e32 100644
--- a/ui.frontend/src/components/ExecutableIdValue.tsx
+++ b/ui.frontend/src/components/ExecutableIdValue.tsx
@@ -17,8 +17,8 @@ const ExecutableIdValue: React.FC<ExecutableIdValueProps> = ({ id }) => {
         <Draft size="XS" /> <Text>Console</Text>
       </>
     );
-  } else if (id.includes('/script/auto/enabled/')) {
-    const scriptPath = id.split('/script/auto/enabled/')[1];
+  } else if (id.includes('/script/automatic/')) {
+    const scriptPath = id.split('/script/automatic/')[1];
     const scriptName = Strings.removeEnd(scriptPath, '.groovy');
     return (
       <>
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 429a548c..0c8fb132 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -6,7 +6,6 @@ export type Executable = {
   arguments: ArgumentValues;
 };
 
-// TODO /conf/acm/settings/script => /apps/acm/script
 export const ScriptRootPath = '/conf/acm/settings/script';
 export const ConsoleDefaultScriptPath = `${ScriptRootPath}/template/core/console.groovy`;
 export const ConsoleDefaultScriptContent = `

From 8545381203e22f9755ba00866900e8a150fb2bc7 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:01:19 +0200
Subject: [PATCH 21/83] Safe script execution

---
 .../vml/es/acm/core/instance/HealthChecker.java    | 13 ++++++++-----
 .../dev/vml/es/acm/core/osgi/InstanceInfo.java     | 14 ++++++--------
 2 files changed, 14 insertions(+), 13 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
index 38f98b2f..cec90aa5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
@@ -14,7 +14,7 @@
 import org.apache.sling.discovery.InstanceDescription;
 import org.apache.sling.discovery.TopologyView;
 import org.osgi.framework.Bundle;
-import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
 import org.osgi.framework.ServiceRegistration;
 import org.osgi.service.component.annotations.*;
 import org.osgi.service.event.Event;
@@ -55,12 +55,12 @@ public class HealthChecker implements EventHandler {
 
     @Activate
     @Modified
-    protected void activate(BundleContext context, Config config) {
+    protected void activate(Config config) {
         this.config = config;
         this.eventCollector = new OsgiEventCollector(config.eventQueueSize());
 
         unregisterEventHandler();
-        registerEventHandler(context, config);
+        registerEventHandler(config);
     }
 
     @Deactivate
@@ -89,6 +89,7 @@ private HealthStatus checkStatus(ResourceResolver resourceResolver) {
         return result;
     }
 
+    // TODO not configurable
     private void checkCluster(HealthStatus result) {
         if (!config.clusterChecking() || !instanceInfo.isCluster()) {
             return;
@@ -195,10 +196,12 @@ private boolean isEventUnstable(Event event) {
                 .anyMatch(etu -> FilenameUtils.wildcardMatch(event.getTopic(), etu));
     }
 
-    private void registerEventHandler(BundleContext context, Config config) {
+    private void registerEventHandler(Config config) {
         Hashtable<String, Object> properties = new Hashtable<>();
         properties.put(EventConstants.EVENT_TOPIC, config.eventTopicsUnstable());
-        eventHandlerRegistration = context.registerService(EventHandler.class, this, properties);
+        eventHandlerRegistration = FrameworkUtil.getBundle(getClass())
+                .getBundleContext()
+                .registerService(EventHandler.class, this, properties);
     }
 
     private void unregisterEventHandler() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/osgi/InstanceInfo.java b/core/src/main/java/dev/vml/es/acm/core/osgi/InstanceInfo.java
index 5f1199a2..34d92cfa 100644
--- a/core/src/main/java/dev/vml/es/acm/core/osgi/InstanceInfo.java
+++ b/core/src/main/java/dev/vml/es/acm/core/osgi/InstanceInfo.java
@@ -6,7 +6,7 @@
 import java.util.regex.Pattern;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.settings.SlingSettingsService;
-import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Modified;
@@ -22,18 +22,15 @@ public class InstanceInfo {
 
     private Config config;
 
-    private BundleContext bundleContext;
+    @Reference
+    private SlingSettingsService slingSettings;
 
     @Activate
     @Modified
-    protected void activate(Config config, BundleContext bundleContext) {
+    protected void activate(Config config) {
         this.config = config;
-        this.bundleContext = bundleContext;
     }
 
-    @Reference
-    private SlingSettingsService slingSettings;
-
     public boolean isAuthor() {
         return isRunMode(InstanceRole.AUTHOR.name());
     }
@@ -64,7 +61,8 @@ private boolean isCloudSdk() {
         String regex = config.versionCloudSdkRegex();
         Pattern pattern = Pattern.compile(regex);
         for (String prop : config.versionBundleContextProps()) {
-            String value = bundleContext.getProperty(prop);
+            String value =
+                    FrameworkUtil.getBundle(getClass()).getBundleContext().getProperty(prop);
             if (StringUtils.isNotBlank(value)) {
                 Matcher matcher = pattern.matcher(value);
                 if (matcher.matches()) {

From 8235330d3f2f90eaeaa1a1ffab835768d23804c1 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:12:06 +0200
Subject: [PATCH 22/83] Safe script execution

---
 .../es/acm/core/script/AutomaticScriptScheduler.java  | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 9a1ac0f2..739fd31d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -208,7 +208,6 @@ private void bootOrScheduleScripts() {
         }
     }
 
-    // TODO what if script changed and was booted, use checksum?
     private void bootScript(Script script, ResourceResolver resourceResolver) {
         AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
         if (!booted.get()) {
@@ -236,19 +235,19 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         try (ExecutionContext context =
                 executor.createContext(ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
             if (executor.isLocked(context)) {
-                LOG.debug("Script '{}' is already locked!", script.getPath());
+                LOG.info("Script '{}' is already locked!", script.getPath());
                 return false;
             }
-            Execution executionPending = executionQueue
+            Execution executionQueued = executionQueue
                     .findByExecutableId(context.getExecutable().getId())
                     .orElse(null);
-            if (executionPending != null) {
-                LOG.debug("Script '{}' is already queued: {}", script.getPath(), executionPending);
+            if (executionQueued != null) {
+                LOG.info("Script '{}' is already queued: {}", script.getPath(), executionQueued);
                 return false;
             }
             Execution executionChecking = executor.check(context);
             if (executionChecking.getStatus() != ExecutionStatus.SUCCEEDED) {
-                LOG.debug("Script '{}' cannot run: {}", script.getPath(), executionChecking);
+                LOG.info("Script '{}' cannot run: {}", script.getPath(), executionChecking);
                 return false;
             }
             return true;

From 60f5fec074a9192fff3e90f1d2bde839f54cfe38 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:14:45 +0200
Subject: [PATCH 23/83] Safe script execution

---
 core/src/main/java/dev/vml/es/acm/core/code/Executor.java | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 49b460b2..5eae309c 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -98,6 +98,8 @@ public Execution execute(ExecutionContext context) throws AcmException {
 
     private ImmediateExecution executeImmediately(ExecutionContext context) {
         ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+        Locker locker = context.getCodeContext().getLocker();
+        String lockName = executableLockName(context);
 
         try {
             statuses.put(context.getId(), ExecutionStatus.PARSING);
@@ -122,12 +124,12 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
             }
 
-            if (context.getCodeContext().getLocker().isLocked(executableLockName(context))) {
+            if (locker.isLocked(lockName)) {
                 return execution.end(ExecutionStatus.SKIPPED);
             }
 
             try {
-                context.getCodeContext().getLocker().lock(executableLockName(context));
+                locker.lock(lockName);
                 statuses.put(context.getId(), ExecutionStatus.RUNNING);
                 if (config.logPrintingEnabled()) {
                     context.getOut().fromSelfLogger();
@@ -136,7 +138,7 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 contentScript.run();
                 return execution.end(ExecutionStatus.SUCCEEDED);
             } finally {
-                context.getCodeContext().getLocker().unlock(executableLockName(context));
+                locker.unlock(lockName);
             }
         } catch (Throwable e) {
             execution.error(e);

From 4ecb6f5d6c38ad975afec6b46587cf68d7d505e2 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:25:19 +0200
Subject: [PATCH 24/83] Safe script execution

---
 .../dev/vml/es/acm/core/script/AutomaticScriptScheduler.java     | 1 +
 1 file changed, 1 insertion(+)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 739fd31d..adee4212 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -208,6 +208,7 @@ private void bootOrScheduleScripts() {
         }
     }
 
+    // TODO should we reboot changed scripts or not ; store checksum instead of boolean?
     private void bootScript(Script script, ResourceResolver resourceResolver) {
         AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
         if (!booted.get()) {

From b03109fc282e91cd5422c5636cfe3b7926176320 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:32:22 +0200
Subject: [PATCH 25/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java | 13 +++++-----
 .../vml/es/acm/core/util/ChecksumUtils.java   | 25 +++++++++++++++++++
 2 files changed, 32 insertions(+), 6 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/util/ChecksumUtils.java

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index adee4212..3e739c1e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -8,12 +8,13 @@
 import dev.vml.es.acm.core.osgi.InstanceInfo;
 import dev.vml.es.acm.core.osgi.InstanceType;
 import dev.vml.es.acm.core.repo.Repo;
+import dev.vml.es.acm.core.util.ChecksumUtils;
 import dev.vml.es.acm.core.util.ResourceUtils;
+
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -51,7 +52,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private Boolean instanceReady;
 
-    private final Map<String, AtomicBoolean> booted = new ConcurrentHashMap<>();
+    private final Map<String, String> booted = new ConcurrentHashMap<>();
 
     private final List<String> scheduled = new CopyOnWriteArrayList<>();
 
@@ -208,13 +209,13 @@ private void bootOrScheduleScripts() {
         }
     }
 
-    // TODO should we reboot changed scripts or not ; store checksum instead of boolean?
     private void bootScript(Script script, ResourceResolver resourceResolver) {
-        AtomicBoolean booted = this.booted.computeIfAbsent(script.getId(), s -> new AtomicBoolean(false));
-        if (!booted.get()) {
+        String checksum = ChecksumUtils.calculate(script.getContent());
+        String previousChecksum = booted.get(script.getId());
+        if (previousChecksum == null || StringUtils.equals(previousChecksum, checksum)) {
             if (checkScript(script, resourceResolver)) {
                 queueScript(script);
-                booted.set(true);
+                booted.put(script.getId(), checksum);
             } else {
                 LOG.info("Boot script '{}' is not ready for queueing!", script.getPath());
             }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ChecksumUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ChecksumUtils.java
new file mode 100644
index 00000000..a792ce9e
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ChecksumUtils.java
@@ -0,0 +1,25 @@
+package dev.vml.es.acm.core.util;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+public final class ChecksumUtils {
+
+    private ChecksumUtils() {
+        // intentionally empty
+    }
+
+    public static String calculate(String content) {
+        if (content == null || content.isEmpty()) {
+            return null;
+        }
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] hash = digest.digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(hash);
+        } catch (NoSuchAlgorithmException e) {
+            return null;
+        }
+    }
+}

From 308749c944e5ea1ac5c5ab265cfd6eb862c157b7 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:39:32 +0200
Subject: [PATCH 26/83] Safe script execution

---
 .../dev/vml/es/acm/core/script/AutomaticScriptScheduler.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 3e739c1e..55e2992b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -212,7 +212,7 @@ private void bootOrScheduleScripts() {
     private void bootScript(Script script, ResourceResolver resourceResolver) {
         String checksum = ChecksumUtils.calculate(script.getContent());
         String previousChecksum = booted.get(script.getId());
-        if (previousChecksum == null || StringUtils.equals(previousChecksum, checksum)) {
+        if (previousChecksum == null || !StringUtils.equals(previousChecksum, checksum)) {
             if (checkScript(script, resourceResolver)) {
                 queueScript(script);
                 booted.put(script.getId(), checksum);

From 9fa8d75cce2a1f09a679f44e2e1089a5781db0f5 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:49:46 +0200
Subject: [PATCH 27/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java    | 16 ++++++++++------
 .../automatic/example/ACME-10_always.groovy      | 11 +++++++++++
 2 files changed, 21 insertions(+), 6 deletions(-)
 create mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 55e2992b..18187c97 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -44,8 +44,10 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
 
+    // TODO make configurable
     private static final long INSTANCE_HEALTHY_INTERVAL = 1000;
 
+    // TODO make configurable
     private static final long INSTANCE_HEALTHY_RETRIES = 60 * 30; // 30 minutes
 
     private static final String BOOT_JOB_NAME = "boot";
@@ -216,8 +218,9 @@ private void bootScript(Script script, ResourceResolver resourceResolver) {
             if (checkScript(script, resourceResolver)) {
                 queueScript(script);
                 booted.put(script.getId(), checksum);
+                LOG.info("Boot script '{}' queued", script.getId());
             } else {
-                LOG.info("Boot script '{}' is not ready for queueing!", script.getPath());
+                LOG.info("Boot script '{}' not ready for queueing!", script.getId());
             }
         }
     }
@@ -228,8 +231,9 @@ private void scheduleScript(Script script, CronSchedule schedule) {
                     cronJob(script.getPath()),
                     configureScheduleOptions(script.getPath(), scheduler.EXPR(schedule.getExpression())));
             scheduled.add(script.getPath());
+            LOG.info("Cron schedule script '{}' scheduled with expression '{}'", script.getId(), schedule.getExpression());
         } else {
-            LOG.error("Cron schedule for script '{}' has no cron expression defined!", script.getPath());
+            LOG.error("Cron schedule script '{}' not scheduled as no expression defined!", script.getId());
         }
     }
 
@@ -237,19 +241,19 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         try (ExecutionContext context =
                 executor.createContext(ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
             if (executor.isLocked(context)) {
-                LOG.info("Script '{}' is already locked!", script.getPath());
+                LOG.info("Script '{}' already locked!", script.getPath());
                 return false;
             }
             Execution executionQueued = executionQueue
                     .findByExecutableId(context.getExecutable().getId())
                     .orElse(null);
             if (executionQueued != null) {
-                LOG.info("Script '{}' is already queued: {}", script.getPath(), executionQueued);
+                LOG.info("Script '{}' already queued: {}", script.getPath(), executionQueued);
                 return false;
             }
             Execution executionChecking = executor.check(context);
             if (executionChecking.getStatus() != ExecutionStatus.SUCCEEDED) {
-                LOG.info("Script '{}' cannot run: {}", script.getPath(), executionChecking);
+                LOG.info("Script '{}' cannot be run: {}", script.getPath(), executionChecking);
                 return false;
             }
             return true;
@@ -271,7 +275,7 @@ private Job cronJob(String scriptPath) {
                     if (checkScript(script, resourceResolver)) {
                         queueScript(script);
                     } else {
-                        LOG.warn("Cron schedule script '{}' is not ready for queueing!", scriptPath);
+                        LOG.info("Cron schedule script '{}' not ready for queueing!", scriptPath);
                     }
                 }
             } catch (LoginException e) {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy
new file mode 100644
index 00000000..4a62b989
--- /dev/null
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy
@@ -0,0 +1,11 @@
+def scheduleRun() {
+    return schedules.boot()
+}
+
+boolean canRun() {
+    return conditions.always()
+}
+
+void doRun() {
+    println("I should run always on boot!")
+}

From f421c1db219096f360e20ecbf7e38f38875da428 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 11:57:35 +0200
Subject: [PATCH 28/83] Safe script execution

---
 core/src/main/java/dev/vml/es/acm/core/AcmConstants.java | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
index 118a9c0d..040d7cfc 100644
--- a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
+++ b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
@@ -2,9 +2,6 @@
 
 public class AcmConstants {
 
-    // TODO move scripts and stuff here
-    public static final String APPS_ROOT = "/apps/acm";
-
     public static final String SETTINGS_ROOT = "/conf/acm/settings";
 
     public static final String VAR_ROOT = "/var/acm";

From 7586373dd89ccf34743f515ea91217f936f87869 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 12:00:13 +0200
Subject: [PATCH 29/83] Safe script execution

---
 README.md | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 70c325db..2e3751d7 100644
--- a/README.md
+++ b/README.md
@@ -249,8 +249,12 @@ Be inspired by reviewing examples like [page thumbnail script](https://github.co
 The following example demonstrates how to create a user and a group, assign permissions, and add members to the group using the [ACL service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/acl/Acl.java) (`acl`).
 
 ```groovy
+def scheduleRun() {
+    return schedules.cron("0 10 * ? * * *") // every hour at minute 10
+}
+
 boolean canRun() {
-    return conditions.everyHour()
+    return conditions.always()
 }
 
 void doRun() {

From 80db0656aad6521a05a2e7c9d83224f88c7440f6 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 12:07:11 +0200
Subject: [PATCH 30/83] Safe script execution

---
 .../acm/settings/script/automatic/example/ACME-100_acl.groovy | 4 ++++
 .../settings/script/automatic/example/ACME-10_always.groovy   | 4 ----
 .../acm/settings/script/automatic/example/ACME-20_once.groovy | 4 ----
 3 files changed, 4 insertions(+), 8 deletions(-)

diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
index 468e2b0b..7f2cd6cb 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy
@@ -2,6 +2,10 @@ def scheduleRun() {
     return schedules.cron("0 10 * ? * * *") // every hour at minute 10
 }
 
+boolean canRun() {
+    return conditions.always()
+}
+
 void doRun() {
     def experiences = [
             [
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy
index 4a62b989..548fdced 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-10_always.groovy
@@ -1,7 +1,3 @@
-def scheduleRun() {
-    return schedules.boot()
-}
-
 boolean canRun() {
     return conditions.always()
 }
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
index 42c47d8d..aaefb5b9 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-20_once.groovy
@@ -1,7 +1,3 @@
-def scheduleRun() {
-    return schedules.boot()
-}
-
 boolean canRun() {
     return conditions.once()
 }

From 42626096cc2684c15d4cd23af0848f6fb2e76caa Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 12:14:26 +0200
Subject: [PATCH 31/83] Safe script execution

---
 .../main/java/dev/vml/es/acm/core/script/ScriptRepository.java   | 1 -
 1 file changed, 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index 2dbc005b..87c6072f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -13,7 +13,6 @@
 
 public class ScriptRepository {
 
-    // TODO /conf/acm/settings/script => /apps/acm/script
     public static final String ROOT = AcmConstants.SETTINGS_ROOT + "/script";
 
     private final ResourceResolver resourceResolver;

From 48a41f70c891278e50d16237251272d69c5d9503 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 12:21:50 +0200
Subject: [PATCH 32/83] Safe script execution

---
 .../dev/vml/es/acm/core/script/AutomaticScriptScheduler.java   | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 18187c97..d353cf75 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -266,6 +266,9 @@ private void queueScript(Script script) {
 
     private Job cronJob(String scriptPath) {
         return context -> {
+            // TODO should it wait for healthy instance?
+            // awaitInstanceHealthy();
+
             try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
                 ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
                 Script script = scriptRepository.read(scriptPath).orElse(null);

From 09056914641001cea8cd036203011045ce0640a8 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 12:26:34 +0200
Subject: [PATCH 33/83] Safe script execution

---
 core/src/main/java/dev/vml/es/acm/core/code/Executor.java | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 5eae309c..ae229f8c 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -124,6 +124,8 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
             }
 
+            // TODO should we wait here for healthy instance
+
             if (locker.isLocked(lockName)) {
                 return execution.end(ExecutionStatus.SKIPPED);
             }

From 0f923b1848df9f9a3f99d48b4cdb4444f4ba60f2 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 12 Aug 2025 21:18:00 +0200
Subject: [PATCH 34/83] Safe script execution

---
 .../dev/vml/es/acm/core/AcmConstants.java     |   2 +
 .../dev/vml/es/acm/core/code/Executor.java    |  18 +-
 .../core/script/AutomaticScriptScheduler.java | 165 +++++++++++-------
 .../vml/es/acm/core/util/ResourceUtils.java   |   5 +-
 4 files changed, 110 insertions(+), 80 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
index 040d7cfc..eb6532f7 100644
--- a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
+++ b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
@@ -2,6 +2,8 @@
 
 public class AcmConstants {
 
+    public static final String CODE = "acm";
+
     public static final String SETTINGS_ROOT = "/conf/acm/settings";
 
     public static final String VAR_ROOT = "/var/acm";
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index ae229f8c..569ba2c1 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -97,17 +97,13 @@ public Execution execute(ExecutionContext context) throws AcmException {
     }
 
     private ImmediateExecution executeImmediately(ExecutionContext context) {
-        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
-        Locker locker = context.getCodeContext().getLocker();
-        String lockName = executableLockName(context);
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start();
 
         try {
             statuses.put(context.getId(), ExecutionStatus.PARSING);
 
             ContentScript contentScript = new ContentScript(context);
 
-            execution.start();
-
             if (context.getMode() == ExecutionMode.PARSE) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
             }
@@ -124,7 +120,8 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
             }
 
-            // TODO should we wait here for healthy instance
+            Locker locker = context.getCodeContext().getLocker();
+            String lockName = executableLockName(context);
 
             if (locker.isLocked(lockName)) {
                 return execution.end(ExecutionStatus.SKIPPED);
@@ -162,10 +159,9 @@ public Optional<ExecutionStatus> checkStatus(String executionId) {
     }
 
     public Description describe(ExecutionContext context) {
-        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start();
         try {
             ContentScript contentScript = new ContentScript(context);
-            execution.start();
             contentScript.describe();
 
             return new Description(execution.end(ExecutionStatus.SUCCEEDED), context.getArguments());
@@ -176,11 +172,10 @@ public Description describe(ExecutionContext context) {
     }
 
     public Execution check(ExecutionContext context) throws AcmException {
-        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start();
 
         try {
             ContentScript contentScript = new ContentScript(context);
-            execution.start();
             boolean canRun = contentScript.canRun();
             if (canRun) {
                 return execution.end(ExecutionStatus.SUCCEEDED);
@@ -194,10 +189,9 @@ public Execution check(ExecutionContext context) throws AcmException {
     }
 
     public ScheduleResult schedule(ExecutionContext context) {
-        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context);
+        ImmediateExecution.Builder execution = new ImmediateExecution.Builder(context).start();
         try {
             ContentScript contentScript = new ContentScript(context);
-            execution.start();
             Schedule schedule = contentScript.schedule();
             return new ScheduleResult(execution.end(ExecutionStatus.SUCCEEDED), schedule);
         } catch (Throwable e) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index d353cf75..a83b2c74 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -1,5 +1,6 @@
 package dev.vml.es.acm.core.script;
 
+import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.code.*;
 import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import dev.vml.es.acm.core.code.schedule.CronSchedule;
@@ -24,10 +25,10 @@
 import org.apache.sling.commons.scheduler.Job;
 import org.apache.sling.commons.scheduler.ScheduleOptions;
 import org.apache.sling.commons.scheduler.Scheduler;
-import org.osgi.service.component.annotations.Activate;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Deactivate;
-import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.*;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -40,17 +41,31 @@
             ResourceChangeListener.CHANGES + "=CHANGED",
             ResourceChangeListener.CHANGES + "=REMOVED"
         })
+@Designate(ocd = AutomaticScriptScheduler.Config.class)
 public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     private static final Logger LOG = LoggerFactory.getLogger(AutomaticScriptScheduler.class);
 
-    // TODO make configurable
-    private static final long INSTANCE_HEALTHY_INTERVAL = 1000;
+    private static final String BOOT_JOB_NAME = AcmConstants.CODE + "-boot";
 
-    // TODO make configurable
-    private static final long INSTANCE_HEALTHY_RETRIES = 60 * 30; // 30 minutes
+    @ObjectClassDefinition(
+            name = "AEM Content Manager - Automatic Script Scheduler",
+            description = "Schedules automatic scripts on instance up and script changes"
+    )
+    public @interface Config {
 
-    private static final String BOOT_JOB_NAME = "boot";
+        @AttributeDefinition(name = "Health Check Interval",
+                description = "Interval in milliseconds to retry health check if instance is not healthy")
+        long healthRetryInterval() default 1000 * 10; // 10 seconds
+
+        @AttributeDefinition(name = "Health Check Max Count - Boot Schedule",
+                description = "Maximum number of retries to check if instance is healthy")
+        long healthRetryMaxCountBoot() default 90; // 90 times * 10 seconds = 15 minutes
+
+        @AttributeDefinition(name = "Health Check Max Count - Cron Schedule",
+                description = "Maximum number of retries to check if instance is healthy")
+        long healthRetryMaxCountCron() default 3; // 3 times * 10 seconds = 30 seconds
+    }
 
     private Boolean instanceReady;
 
@@ -76,16 +91,28 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     @Reference
     private ExecutionQueue executionQueue;
 
+    private Config config;
+
     @Activate
-    protected void activate() {
+    protected void activate(Config config) {
+        this.config = config;
+
         if (checkInstanceReady()) {
             bootWhenInstanceUp();
         }
     }
 
+    @Modified
+    protected void modify(Config config) {
+        this.config = config;
+    }
+
     @Deactivate
     protected void deactivate() {
+        unscheduleBoot();
         unscheduleScripts();
+        booted.clear();
+        instanceReady = null;
     }
 
     @Override
@@ -97,16 +124,21 @@ public void onChange(List<ResourceChange> changes) {
 
     private void bootWhenInstanceUp() {
         LOG.info("Automatic scripts booting on instance up");
+        unscheduleBoot();
         scheduleBoot();
     }
 
     private void bootWhenScriptsChanged() {
         LOG.info("Automatic scripts booting on script changes");
+        unscheduleBoot();
         scheduleBoot();
     }
 
-    private void scheduleBoot() {
+    private void unscheduleBoot() {
         scheduler.unschedule(BOOT_JOB_NAME);
+    }
+
+    private void scheduleBoot() {
         scheduler.schedule(bootJob(), configureScheduleOptions(BOOT_JOB_NAME, scheduler.NOW()));
     }
 
@@ -128,8 +160,8 @@ private ScheduleResult determineSchedule(Script script, ResourceResolver resourc
     private Job bootJob() {
         return context -> {
             unscheduleScripts();
-            if (awaitInstanceHealthy()) {
-                bootOrScheduleScripts();
+            if (awaitInstanceHealthy("Automatic scripts queueing and scheduling", config.healthRetryMaxCountBoot(), config.healthRetryInterval())) {
+                queueAndScheduleScripts();
             }
         };
     }
@@ -150,36 +182,7 @@ private boolean checkInstanceReady() {
         return instanceReady;
     }
 
-    private boolean awaitInstanceHealthy() {
-        HealthStatus healthStatus = null;
-        long retries = 0;
-        while (healthStatus == null || !healthStatus.isHealthy()) {
-            if (retries >= INSTANCE_HEALTHY_RETRIES) {
-                LOG.error(
-                        "Automatic scripts booting failed after {} retries due to health status: {}",
-                        INSTANCE_HEALTHY_RETRIES,
-                        healthStatus);
-                return false;
-            }
-            healthStatus = healthChecker.checkStatus();
-            if (healthStatus.isHealthy()) {
-                break;
-            } else {
-                LOG.warn("Automatic scripts booting paused due to health status: {}", healthStatus);
-                try {
-                    Thread.sleep(INSTANCE_HEALTHY_INTERVAL); // wait before retrying
-                    retries++;
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
-                    LOG.error("Automatic scripts booting interrupted!", e);
-                }
-            }
-        }
-        return true;
-    }
-
     private void unscheduleScripts() {
-        scheduler.unschedule(BOOT_JOB_NAME);
         for (String scriptPath : scheduled) {
             try {
                 scheduler.unschedule(scriptPath);
@@ -190,7 +193,7 @@ private void unscheduleScripts() {
         scheduled.clear();
     }
 
-    private void bootOrScheduleScripts() {
+    private void queueAndScheduleScripts() {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
             scriptRepository.findAll(ScriptType.AUTOMATIC).forEach(script -> {
@@ -198,9 +201,9 @@ private void bootOrScheduleScripts() {
                 if (scheduleResult.getExecution().getStatus() == ExecutionStatus.SUCCEEDED) {
                     Schedule schedule = scheduleResult.getSchedule();
                     if (schedule instanceof BootSchedule) {
-                        bootScript(script, resourceResolver);
+                        queueBootScript(script, resourceResolver);
                     } else if (schedule instanceof CronSchedule) {
-                        scheduleScript(script, (CronSchedule) schedule);
+                        scheduleCronScript(script, (CronSchedule) schedule);
                     }
                 } else {
                     LOG.error("Automatic script schedule cannot be determined: {}", scheduleResult.getExecution());
@@ -211,7 +214,7 @@ private void bootOrScheduleScripts() {
         }
     }
 
-    private void bootScript(Script script, ResourceResolver resourceResolver) {
+    private void queueBootScript(Script script, ResourceResolver resourceResolver) {
         String checksum = ChecksumUtils.calculate(script.getContent());
         String previousChecksum = booted.get(script.getId());
         if (previousChecksum == null || !StringUtils.equals(previousChecksum, checksum)) {
@@ -225,7 +228,7 @@ private void bootScript(Script script, ResourceResolver resourceResolver) {
         }
     }
 
-    private void scheduleScript(Script script, CronSchedule schedule) {
+    private void scheduleCronScript(Script script, CronSchedule schedule) {
         if (StringUtils.isNotBlank(schedule.getExpression())) {
             scheduler.schedule(
                     cronJob(script.getPath()),
@@ -237,6 +240,30 @@ private void scheduleScript(Script script, CronSchedule schedule) {
         }
     }
 
+    private Job cronJob(String scriptPath) {
+        return context -> {
+            if (!awaitInstanceHealthy(String.format("Cron script queueing '%s'", scriptPath), config.healthRetryMaxCountCron(), config.healthRetryInterval())) {
+                return;
+            }
+
+            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+                ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
+                Script script = scriptRepository.read(scriptPath).orElse(null);
+                if (script == null) {
+                    LOG.error("Cron schedule script '{}' not found in repository!", scriptPath);
+                } else {
+                    if (checkScript(script, resourceResolver)) {
+                        queueScript(script);
+                    } else {
+                        LOG.info("Cron schedule script '{}' not ready for queueing!", scriptPath);
+                    }
+                }
+            } catch (LoginException e) {
+                LOG.error("Cannot access repository while queueing cron schedule script '{}'!", scriptPath, e);
+            }
+        };
+    }
+
     private boolean checkScript(Script script, ResourceResolver resourceResolver) {
         try (ExecutionContext context =
                 executor.createContext(ExecutionId.generate(), ExecutionMode.PARSE, script, resourceResolver)) {
@@ -264,26 +291,32 @@ private void queueScript(Script script) {
         executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, null));
     }
 
-    private Job cronJob(String scriptPath) {
-        return context -> {
-            // TODO should it wait for healthy instance?
-            // awaitInstanceHealthy();
-
-            try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
-                ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
-                Script script = scriptRepository.read(scriptPath).orElse(null);
-                if (script == null) {
-                    LOG.error("Cron schedule script '{}' not found in repository!", scriptPath);
-                } else {
-                    if (checkScript(script, resourceResolver)) {
-                        queueScript(script);
-                    } else {
-                        LOG.info("Cron schedule script '{}' not ready for queueing!", scriptPath);
-                    }
+    private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long retryInterval) {
+        HealthStatus healthStatus = null;
+        long retryCount = 0;
+        while (healthStatus == null || !healthStatus.isHealthy()) {
+            if (retryCount >= retryMaxCount) {
+                LOG.error(
+                        "{} failed after {} retries due to health status: {}",
+                        operation,
+                        retryMaxCount,
+                        healthStatus);
+                return false;
+            }
+            healthStatus = healthChecker.checkStatus();
+            if (healthStatus.isHealthy()) {
+                break;
+            } else {
+                LOG.warn("{} paused due to health status: {}", operation, healthStatus);
+                try {
+                    Thread.sleep(retryInterval); // wait before retrying
+                    retryCount++;
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    LOG.error("{} interrupted!", operation, e);
                 }
-            } catch (LoginException e) {
-                LOG.error("Cannot access repository while queueing cron schedule script '{}'!", scriptPath, e);
             }
-        };
+        }
+        return true;
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index 72c3dee0..1c312b0e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -1,5 +1,6 @@
 package dev.vml.es.acm.core.util;
 
+import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.AcmException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -13,8 +14,8 @@
 public final class ResourceUtils {
 
     public enum Subservice {
-        CONTENT("acm-content-service"),
-        MOCK("acm-mock-service");
+        CONTENT(AcmConstants.CODE + "-content-service"),
+        MOCK(AcmConstants.CODE + "-mock-service");
 
         public final String userId;
 

From 4e8116e696803e4c1927a2cef51b14a0a52ed424 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 08:54:24 +0200
Subject: [PATCH 35/83] Safe script execution

---
 .../dev/vml/es/acm/core/code/Conditions.java  |  3 +-
 .../core/code/ExecutionContextOptions.java    |  4 ++-
 .../dev/vml/es/acm/core/code/Schedules.java   |  5 +++
 .../acm/core/code/schedule/NoneSchedule.java  | 11 ++++++
 .../core/script/AutomaticScriptScheduler.java | 35 ++++++++++++-------
 .../example/ACME-30_every-minutes.groovy      | 11 ++++++
 ...-hour.groovy => ACME-31_every-hour.groovy} |  0
 ...ry-day.groovy => ACME-32_every-day.groovy} |  0
 ...-week.groovy => ACME-33_every-week.groovy} |  0
 ...onth.groovy => ACME-34_every-month.groovy} |  0
 .../available/core/schedule/.content.xml      |  3 ++
 .../snippet/available/core/schedule/boot.yml  |  9 +++++
 .../snippet/available/core/schedule/cron.yml  | 10 ++++++
 .../snippet/available/core/schedule/none.yml  |  8 +++++
 14 files changed, 84 insertions(+), 15 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/schedule/NoneSchedule.java
 create mode 100644 ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-30_every-minutes.groovy
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/{ACME-21_every-hour.groovy => ACME-31_every-hour.groovy} (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/{ACME-22_every-day.groovy => ACME-32_every-day.groovy} (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/{ACME-23_every-week.groovy => ACME-33_every-week.groovy} (100%)
 rename ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/{ACME-24_every-month.groovy => ACME-34_every-month.groovy} (100%)
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/.content.xml
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/boot.yml
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/none.yml

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
index 1c7fd22e..74322830 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
@@ -58,7 +58,8 @@ public Stream<Execution> passedExecutions() {
     }
 
     public boolean notSucceeded() {
-        return false; // TODO implement this
+        Execution passedExecution = passedExecution();
+        return passedExecution == null || passedExecution.getStatus() != ExecutionStatus.SUCCEEDED;
     }
 
     public boolean isChangedExecutableContent(Execution execution) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
index 0ce632cd..0e6ceb45 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
@@ -4,6 +4,8 @@
 import java.io.Serializable;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
+
 import org.apache.sling.event.jobs.Job;
 
 /**
@@ -17,7 +19,7 @@ public class ExecutionContextOptions implements Serializable {
 
     public ExecutionContextOptions(ExecutionMode executionMode, String userId) {
         this.executionMode = executionMode;
-        this.userId = userId;
+        this.userId = Objects.requireNonNull(userId);
     }
 
     public static Map<String, Object> toJobProps(ExecutionContextOptions options) throws AcmException {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java b/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
index 716e542b..7f1204a2 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Schedules.java
@@ -2,9 +2,14 @@
 
 import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import dev.vml.es.acm.core.code.schedule.CronSchedule;
+import dev.vml.es.acm.core.code.schedule.NoneSchedule;
 
 public class Schedules {
 
+    public NoneSchedule none() {
+        return new NoneSchedule();
+    }
+
     public BootSchedule boot() {
         return new BootSchedule();
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/schedule/NoneSchedule.java b/core/src/main/java/dev/vml/es/acm/core/code/schedule/NoneSchedule.java
new file mode 100644
index 00000000..c6ce0129
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/code/schedule/NoneSchedule.java
@@ -0,0 +1,11 @@
+package dev.vml.es.acm.core.code.schedule;
+
+import dev.vml.es.acm.core.code.Schedule;
+
+public class NoneSchedule implements Schedule {
+
+    @Override
+    public String getId() {
+        return "none";
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index a83b2c74..4445e3b8 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -4,6 +4,7 @@
 import dev.vml.es.acm.core.code.*;
 import dev.vml.es.acm.core.code.schedule.BootSchedule;
 import dev.vml.es.acm.core.code.schedule.CronSchedule;
+import dev.vml.es.acm.core.code.schedule.NoneSchedule;
 import dev.vml.es.acm.core.instance.HealthChecker;
 import dev.vml.es.acm.core.instance.HealthStatus;
 import dev.vml.es.acm.core.osgi.InstanceInfo;
@@ -54,17 +55,19 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
     )
     public @interface Config {
 
+        @AttributeDefinition(
+                name = "User Impersonation ID",
+                description =
+                        "Controls who accesses the repository when scripts are automatically executed. If blank, the service user is used.")
+        String userImpersonationId();
+
         @AttributeDefinition(name = "Health Check Interval",
                 description = "Interval in milliseconds to retry health check if instance is not healthy")
         long healthRetryInterval() default 1000 * 10; // 10 seconds
 
-        @AttributeDefinition(name = "Health Check Max Count - Boot Schedule",
-                description = "Maximum number of retries to check if instance is healthy")
+        @AttributeDefinition(name = "Health Check Max Count - Boot",
+                description = "Maximum number of retries after booting instance or installing package with automatic scripts")
         long healthRetryMaxCountBoot() default 90; // 90 times * 10 seconds = 15 minutes
-
-        @AttributeDefinition(name = "Health Check Max Count - Cron Schedule",
-                description = "Maximum number of retries to check if instance is healthy")
-        long healthRetryMaxCountCron() default 3; // 3 times * 10 seconds = 30 seconds
     }
 
     private Boolean instanceReady;
@@ -204,6 +207,10 @@ private void queueAndScheduleScripts() {
                         queueBootScript(script, resourceResolver);
                     } else if (schedule instanceof CronSchedule) {
                         scheduleCronScript(script, (CronSchedule) schedule);
+                    } else if (schedule instanceof NoneSchedule) {
+                        LOG.info("Automatic script '{}' skipped due to schedule 'none'", script.getId());
+                    } else {
+                        LOG.info("Automatic script '{}' skipped due to schedule '{}' (unsupported)", script.getId(), schedule.getId());
                     }
                 } else {
                     LOG.error("Automatic script schedule cannot be determined: {}", scheduleResult.getExecution());
@@ -242,10 +249,6 @@ private void scheduleCronScript(Script script, CronSchedule schedule) {
 
     private Job cronJob(String scriptPath) {
         return context -> {
-            if (!awaitInstanceHealthy(String.format("Cron script queueing '%s'", scriptPath), config.healthRetryMaxCountCron(), config.healthRetryInterval())) {
-                return;
-            }
-
             try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
                 ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
                 Script script = scriptRepository.read(scriptPath).orElse(null);
@@ -288,7 +291,8 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
     }
 
     private void queueScript(Script script) {
-        executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, null));
+        String userId = StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
+        executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, userId));
     }
 
     private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long retryInterval) {
@@ -297,7 +301,7 @@ private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long
         while (healthStatus == null || !healthStatus.isHealthy()) {
             if (retryCount >= retryMaxCount) {
                 LOG.error(
-                        "{} failed after {} retries due to health status: {}",
+                        "{} not reached healthy instance state after {} retries: {}",
                         operation,
                         retryMaxCount,
                         healthStatus);
@@ -307,7 +311,7 @@ private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long
             if (healthStatus.isHealthy()) {
                 break;
             } else {
-                LOG.warn("{} paused due to health status: {}", operation, healthStatus);
+                LOG.warn("{} paused due to unhealthy instance state: {}", operation, healthStatus);
                 try {
                     Thread.sleep(retryInterval); // wait before retrying
                     retryCount++;
@@ -317,6 +321,11 @@ private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long
                 }
             }
         }
+        if (retryCount > 0) {
+            LOG.info("{} reached healthy instance state after {} retries: {}", operation, retryCount, healthStatus);
+        } else {
+            LOG.info("{} reached healthy instance state: {}", operation, healthStatus);
+        }
         return true;
     }
 }
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-30_every-minutes.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-30_every-minutes.groovy
new file mode 100644
index 00000000..7f74cf1b
--- /dev/null
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-30_every-minutes.groovy
@@ -0,0 +1,11 @@
+def scheduleRun() {
+    return schedules.cron("0 0/5 * * * ?")
+}
+
+boolean canRun() {
+    return conditions.always()
+}
+
+void doRun() {
+    println("I should run every 5 minutes!")
+}
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-31_every-hour.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-21_every-hour.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-31_every-hour.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-32_every-day.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-22_every-day.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-32_every-day.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-33_every-week.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-23_every-week.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-33_every-week.groovy
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-34_every-month.groovy
similarity index 100%
rename from ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-24_every-month.groovy
rename to ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-34_every-month.groovy
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/.content.xml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/.content.xml
new file mode 100644
index 00000000..491392d5
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/.content.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
+    jcr:primaryType="sling:Folder"/>
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/boot.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/boot.yml
new file mode 100644
index 00000000..0d012bb3
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/boot.yml
@@ -0,0 +1,9 @@
+group: Schedule
+name: schedule_boot
+content: |
+  schedules.boot()
+documentation: |
+  Schedules the script to run at system startup or when a package containing such an automatic script is installed.
+  
+  Each script is executed only once per system startup. 
+  If a script using this schedule is installed after startup, ACM ensures that the same version of each script runs only once during a single instance boot.
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
new file mode 100644
index 00000000..7779d154
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
@@ -0,0 +1,10 @@
+group: Schedule
+name: schedule_cron
+content: |
+  schedules.cron(${1:expression})
+documentation: |
+  Schedules the script to run at specified intervals using a cron expression.
+
+  AEM uses the Quartz scheduler, and so does ACM. Both require a specific syntax for cron expressions.
+  For details, refer to the [Quartz documentation](https://www.quartz-scheduler.org/documentation).
+  To generate valid cron expressions, you can use online tools such as [FreeFormatter](https://www.freeformatter.com/cron-expression-generator-quartz.html).
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/none.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/none.yml
new file mode 100644
index 00000000..24df710e
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/none.yml
@@ -0,0 +1,8 @@
+group: Schedule
+name: schedule_none
+content: |
+  schedules.none()
+documentation: |
+  Effectively disables automatic script execution. 
+  
+  Useful to mark a script as archived or disabling it without removing it from the system.

From 33b38fd1ce0b112df0a61c333f6bb5528fe72e5d Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 09:44:54 +0200
Subject: [PATCH 36/83] Safe script execution

---
 .../core/script/AutomaticScriptScheduler.java |  2 +-
 .../available/core/general/script_can_run.yml | 35 +++++++++++++++++--
 .../core/general/script_describe_run.yml      |  2 +-
 .../core/general/script_schedule_run.yml      | 31 ++++++++++++++++
 .../src/components/ScriptAutomaticList.tsx    | 13 +++++--
 ui.frontend/src/components/ScriptExecutor.tsx |  4 ---
 6 files changed, 75 insertions(+), 12 deletions(-)
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_schedule_run.yml

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 4445e3b8..353fa215 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -58,7 +58,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
         @AttributeDefinition(
                 name = "User Impersonation ID",
                 description =
-                        "Controls who accesses the repository when scripts are automatically executed. If blank, the service user is used.")
+                        "Controls who accesses the repository when scripts are automatically executed. If blank, the service user 'acm-content-service' is used.")
         String userImpersonationId();
 
         @AttributeDefinition(name = "Health Check Interval",
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_can_run.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_can_run.yml
index e35c44c3..d2f5f12c 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_can_run.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_can_run.yml
@@ -2,9 +2,38 @@ group: General
 name: general_script_can_run
 content: |
   boolean canRun() {
-    // condition
+    // return conditions.xxx()
   }
 documentation: |
-  Checks if the script can be executed.
+  Determines whether the script is eligible for execution.
+  Allows to skip execution (and history entries) based on any conditions, such as the script author, changes in the repository, or previous execution results.
   
-  This method is executed after `describeRun()` and before `doRun()` methods.
\ No newline at end of file
+  This method is called after `describeRun()` and before `doRun()`.
+  
+  **For console or manual execution**, typically define as:
+  ```groovy
+  boolean canRun() {
+    return conditions.always()
+  }
+  ```
+  
+  **For automatic execution**, usually define to run only once:
+  ```groovy
+  boolean canRun() {
+      return conditions.once()
+  }
+  ```
+  
+  **Or until the script succeeds**:
+  ```groovy
+  boolean canRun() {
+      return conditions.notSucceeded()
+  }
+  ```
+  
+  All conditions could be combined with logical operators:
+  ```groovy
+  boolean canRun() {
+      return conditions.isInstanceAuthor() && (conditions.changed() || conditions.notSucceeded())
+  }
+  ```
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_describe_run.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_describe_run.yml
index a6315e85..cf230af1 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_describe_run.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_describe_run.yml
@@ -5,7 +5,7 @@ content: |
     // arguments.xxx(name) { ... }
   }
 documentation: |
-  Describes the script characteristics.
+  Describes the script characteristics like input arguments for manual scripts.
   
   This method is executed before the script is run and is used to define the script's input arguments.<br>
   The arguments must be defined using the `arguments.xxx(name) { ... }` methods which could be later on used in the script by calling `arguments.value(name)` method.<br>
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_schedule_run.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_schedule_run.yml
new file mode 100644
index 00000000..b3e3dafa
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/script_schedule_run.yml
@@ -0,0 +1,31 @@
+group: General
+name: general_script_schedule_run
+content: |
+  def scheduleRun() {
+    // return schedules.xxx()
+  }
+documentation: |
+  Determines the schedule for the script to run. 
+  
+  This method is executed by automatic script executor to schedule script execution on boot or at cron expression.
+  
+  Default schedule (method could be omitted) for instance boot:
+  ```groovy
+    def scheduleRun() {
+        return schedules.boot()
+    }
+  ```
+  
+  Example schedule using [Quartz](https://www.quartz-scheduler.org/documentation) cron expression (e.g. every day at 10:00 AM):
+  ```groovy
+  def scheduleRun() {
+      return schedules.cron("0 0 10 * * ?")
+  }
+  ```
+  
+  Example schedule for a specific date and time (1st July 2024 at 10:00 AM):
+  ```groovy
+  def scheduleRun() {
+      return schedules.cron("0 0 10 1 7 ? 2024") 
+  }
+  ```
\ No newline at end of file
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index 79009452..14fdb481 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -6,12 +6,13 @@ import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useFormatter } from '../hooks/formatter';
 import { useScripts } from '../hooks/script';
-import { isExecutionNegative, ScriptType } from '../utils/api.types';
+import {instancePrefix, InstanceType, isExecutionNegative, ScriptType} from '../utils/api.types';
 import DateExplained from './DateExplained';
 import ExecutionStatsBadge from './ExecutionStatsBadge';
 import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
+import Settings from "@spectrum-icons/workflow/Settings";
 
 const ScriptAutomaticList: React.FC = () => {
   const appState = useAppState();
@@ -41,8 +42,14 @@ const ScriptAutomaticList: React.FC = () => {
         <Flex direction="row" justifyContent="space-between" alignItems="center">
           <Flex flex="1" alignItems="center">
             <ButtonGroup>
-              &nbsp;
-              {/* TODO toggle scheduler button */}
+              <Button
+                  variant="negative"
+                  isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
+                  onPress={() => window.open(`${instancePrefix}/system/console/configMgr/\tdev.vml.es.acm.core.script.AutomaticScriptScheduler`, '_blank')}
+              >
+                <Settings />
+                <Text>Configure</Text>
+              </Button>
             </ButtonGroup>
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/ScriptExecutor.tsx
index 3c5067af..e7cce100 100644
--- a/ui.frontend/src/components/ScriptExecutor.tsx
+++ b/ui.frontend/src/components/ScriptExecutor.tsx
@@ -87,10 +87,6 @@ const ScriptExecutor = () => {
                   <Text>Configure</Text>
                 </Button>
                 <Menu onAction={(pid) => window.open(`${prefix}/system/console/configMgr/${pid}`, '_blank')}>
-                  <Item key="dev.vml.es.acm.core.script.ScriptScheduler">
-                    <ApplicationDelivery />
-                    <Text>Script Scheduler</Text>
-                  </Item>
                   <Item key="org.apache.sling.event.jobs.QueueConfiguration~acmexecutionqueue">
                     <Clock />
                     <Text>Execution Queue</Text>

From b0b64301b92123d9e84f6df62dcfdd859e840737 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 10:58:28 +0200
Subject: [PATCH 37/83] Safe script execution

---
 .../vml/es/acm/core/script/AutomaticScriptScheduler.java    | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 353fa215..4b050d01 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -230,7 +230,7 @@ private void queueBootScript(Script script, ResourceResolver resourceResolver) {
                 booted.put(script.getId(), checksum);
                 LOG.info("Boot script '{}' queued", script.getId());
             } else {
-                LOG.info("Boot script '{}' not ready for queueing!", script.getId());
+                LOG.info("Boot script '{}' not eligible for queueing!", script.getId());
             }
         }
     }
@@ -258,7 +258,7 @@ private Job cronJob(String scriptPath) {
                     if (checkScript(script, resourceResolver)) {
                         queueScript(script);
                     } else {
-                        LOG.info("Cron schedule script '{}' not ready for queueing!", scriptPath);
+                        LOG.info("Cron schedule script '{}' not eligible for queueing!", scriptPath);
                     }
                 }
             } catch (LoginException e) {
@@ -283,7 +283,7 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
             }
             Execution executionChecking = executor.check(context);
             if (executionChecking.getStatus() != ExecutionStatus.SUCCEEDED) {
-                LOG.info("Script '{}' cannot be run: {}", script.getPath(), executionChecking);
+                LOG.info("Script '{}' checking not succeeded: {}", script.getPath(), executionChecking);
                 return false;
             }
             return true;

From 3b09cc898a89a8fc0c858727ffcb8ff13465e169 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 11:05:51 +0200
Subject: [PATCH 38/83] Safe script execution

---
 README.md | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 2e3751d7..03f6456e 100644
--- a/README.md
+++ b/README.md
@@ -191,9 +191,8 @@ The ACM Console is interactive and offers the following features:
 Content scripts in ACM are Groovy scripts that can be used to automate various tasks in AEM. 
 These scripts can be placed in specific locations within the AEM repository to control their execution behavior.
 
-- `/conf/acm/settings/script/boot/{project}`: Automatically executed scripts on instance boot, usually run once after deployment. Specific conditions could be narrowed by 'canRun()' method.
-- `/conf/acm/settings/script/schedule/{project}`: Automatically executed scripts on schedule every minute. Specific conditions could be narrowed by 'canRun()' method.
-- `/conf/acm/settings/script/manual/{project}`: Manually executed scripts, run under specific circumstances by platform administrators.
+- `/conf/acm/settings/script/automatic/{project}`: Automatically executed scripts on instance boot (usually run once after deployment) or on scheduled intervals defined by `scheduleRun()` method. Specific conditions could be narrowed by `canRun()` method.
+- `/conf/acm/settings/script/manual/{project}`: Manually executed scripts (usually with arguments), run under specific circumstances by platform administrators.
 
 #### Minimal example
 
@@ -246,7 +245,7 @@ Be inspired by reviewing examples like [page thumbnail script](https://github.co
 
 #### ACL example
 
-The following example demonstrates how to create a user and a group, assign permissions, and add members to the group using the [ACL service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/acl/Acl.java) (`acl`).
+The following example of the automatic script demonstrates how to create a user and a group, assign permissions, and add members to the group using the [ACL service](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/acl/Acl.java) (`acl`).
 
 ```groovy
 def scheduleRun() {

From f600c29f5654ad6b5cfd31f4648e2c20cacfa6dc Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 11:16:19 +0200
Subject: [PATCH 39/83] Safe script execution

---
 .../main/java/dev/vml/es/acm/core/code/ExecutionQueue.java | 2 --
 .../java/dev/vml/es/acm/core/instance/HealthChecker.java   | 7 +------
 2 files changed, 1 insertion(+), 8 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 01addebc..da871484 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -69,8 +69,6 @@ protected void deactivate() {
         }
     }
 
-    // TODO do prechecks before adding to queue
-    // [1] check if not queued already, [2] check if canRun, [3] check if not locked
     public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
diff --git a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
index cec90aa5..9c21537f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/instance/HealthChecker.java
@@ -89,9 +89,8 @@ private HealthStatus checkStatus(ResourceResolver resourceResolver) {
         return result;
     }
 
-    // TODO not configurable
     private void checkCluster(HealthStatus result) {
-        if (!config.clusterChecking() || !instanceInfo.isCluster()) {
+        if (!instanceInfo.isCluster()) {
             return;
         }
         if (!isClusterLeader()) {
@@ -216,10 +215,6 @@ private void checkComponents(HealthStatus result) {
 
     @ObjectClassDefinition(name = "AEM Content Manager - Health Checker")
     public @interface Config {
-        @AttributeDefinition(
-                name = "Cluster Checking",
-                description = "Check if the instance is an author instance cluster leader. Supported only on AEMaaCS")
-        boolean clusterChecking() default true;
 
         @AttributeDefinition(name = "Bundle Checking")
         boolean bundleChecking() default true;

From 03087e97cb18cf8f6d21c014e067da4f67d0198b Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 12:20:14 +0200
Subject: [PATCH 40/83] Safe script execution

---
 .../es/acm/core/code/ExecutionHistory.java    | 11 ++--
 .../java/dev/vml/es/acm/core/code/Locker.java |  6 +--
 .../vml/es/acm/core/mock/MockRepository.java  | 14 +++--
 .../vml/es/acm/core/repo/RepoResource.java    | 17 +++---
 .../es/acm/core/script/ScriptRepository.java  | 11 +++-
 .../acm/core/snippet/SnippetRepository.java   |  9 +++-
 .../vml/es/acm/core/util/ResourceUtils.java   | 52 +++++++++++++++----
 7 files changed, 87 insertions(+), 33 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
index 421a4481..e90b3ae5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
@@ -30,14 +30,13 @@ public Stream<Execution> read() {
         return Stream.empty();
     }
 
-    // TODO do not save 'sling:resourceType' = 'sling:Folder' here (avoid corrupting indexes when many data is saved)
     public void save(ExecutionContext context, ImmediateExecution execution) throws AcmException {
         Resource root = getOrCreateRoot();
         Map<String, Object> props = HistoricalExecution.toMap(context, execution);
 
         try {
             String dirPath = root.getPath() + "/" + StringUtils.substringBeforeLast(execution.getId(), "/");
-            Resource dir = ResourceUtils.makeFolders(resourceResolver, dirPath, JcrResourceConstants.NT_SLING_FOLDER);
+            Resource dir = ResourceUtils.ensure(resourceResolver, dirPath, JcrResourceConstants.NT_SLING_FOLDER);
             String entryName = StringUtils.substringAfterLast(execution.getId(), "/");
             resourceResolver.create(dir, entryName, props);
             resourceResolver.commit();
@@ -69,7 +68,13 @@ private Optional<Resource> readResource(String id) {
     }
 
     private Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, ROOT, JcrConstants.NT_FOLDER);
+        try {
+            Resource root = ResourceUtils.ensure(resourceResolver, ROOT, JcrConstants.NT_FOLDER);
+            resourceResolver.commit();
+            return root;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create execution history root '%s'!", ROOT), e);
+        }
     }
 
     public boolean contains(String id) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
index 00c37231..4e6121c5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
@@ -13,6 +13,7 @@
 import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -38,7 +39,6 @@ public boolean isLocked(String lockName) {
         return isLock(lock);
     }
 
-    // TODO do not save 'sling:resourceType'
     public void lock(String lockName) {
         String name = normalizeName(lockName);
         Resource lock = getLock(name);
@@ -52,9 +52,9 @@ public void lock(String lockName) {
             if (name.contains("/")) {
                 String dirPath = StringUtils.substringBeforeLast(name, "/");
                 nodeName = StringUtils.substringAfterLast(name, "/");
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT + "/" + dirPath, JcrConstants.NT_UNSTRUCTURED);
+                dirResource = ResourceUtils.ensure(resolver, ROOT + "/" + dirPath, JcrResourceConstants.NT_SLING_FOLDER);
             } else {
-                dirResource = ResourceUtils.makeFolders(resolver, ROOT, JcrConstants.NT_UNSTRUCTURED);
+                dirResource = ResourceUtils.ensure(resolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
                 nodeName = name;
             }
             Map<String, Object> props = new HashMap<>();
diff --git a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
index c0b0bbab..265b7a42 100644
--- a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
@@ -29,10 +29,6 @@ public MockRepository(ResourceResolver resolver) {
         this.resolver = resolver;
     }
 
-    public Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resolver, ScriptType.MOCK.root(), JcrResourceConstants.NT_SLING_FOLDER);
-    }
-
     public boolean checkResource(Resource resource) {
         return resource.getName().endsWith(".groovy") && resource.isResourceType(JcrConstants.NT_FILE);
     }
@@ -58,4 +54,14 @@ public Optional<Mock> findSpecial(String subPath) {
     public boolean isSpecial(String id) {
         return SPECIAL_PATHS.stream().anyMatch(n -> StringUtils.endsWith(id, "/" + n));
     }
+
+    public Resource getOrCreateRoot() throws AcmException {
+        try {
+            Resource root = ResourceUtils.ensure(resolver, ScriptType.MOCK.root(), JcrResourceConstants.NT_SLING_FOLDER);
+            resolver.commit();
+            return root;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create mock root '%s'!", ScriptType.MOCK.root()), e);
+        }
+    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
index 745f52c0..8f194018 100644
--- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
+++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
@@ -82,7 +82,6 @@ public boolean exists() {
         return get().isPresent();
     }
 
-    // TODO does not work
     public RepoResource ensureFolder() {
         return ensure(JcrConstants.NT_FOLDER);
     }
@@ -95,20 +94,18 @@ public RepoResource ensureOrderedFolder() {
         return ensure(JcrResourceConstants.NT_SLING_ORDERED_FOLDER);
     }
 
-    public RepoResource ensure(String resourceType) {
+    public RepoResource ensure(String primaryType) {
         Resource resource = resolve();
         if (resource == null) {
             try {
-                ResourceUtil.getOrCreateResource(
-                        repo.getResourceResolver(), path, Collections.emptyMap(), resourceType, repo.isAutoCommit());
-            } catch (PersistenceException e) {
-                throw new RepoException(
-                        String.format("Cannot ensure resource '%s' at path '%s'!", resourceType, path), e);
+                ResourceUtils.ensure(repo.getResourceResolver(), path, primaryType);
+            } catch (Exception e) {
+                throw new RepoException(String.format("Cannot ensure resource '%s' at path '%s'!", primaryType, path), e);
             }
-            repo.commit(String.format("ensuring resource '%s' at path %s", resourceType, path));
-            LOG.info("Ensured resource '{}' at path '{}'", resourceType, path);
+            repo.commit(String.format("ensuring resource '%s' at path %s", primaryType, path));
+            LOG.info("Ensured resource '{}' at path '{}'", primaryType, path);
         } else {
-            LOG.info("Skipped ensuring resource '{}' at path '{}'", resourceType, path);
+            LOG.info("Skipped ensuring resource '{}' at path '{}'", primaryType, path);
         }
         return this;
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index 87c6072f..662706ef 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -7,6 +7,8 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
+
+import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.jcr.resource.api.JcrResourceConstants;
@@ -45,6 +47,13 @@ public Stream<Script> readAll(List<String> ids) {
     }
 
     private Resource getOrCreateRoot(ScriptType type) throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, type.root(), JcrResourceConstants.NT_SLING_FOLDER);
+        try {
+            Resource root = ResourceUtils.ensure(resourceResolver, type.root(), JcrResourceConstants.NT_SLING_FOLDER);
+            resourceResolver.commit();
+            return root;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create script root '%s'!", type.root()), e);
+        }
+
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java b/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
index e711da78..c2e8ab19 100644
--- a/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/snippet/SnippetRepository.java
@@ -8,6 +8,7 @@
 import java.util.Optional;
 import java.util.stream.Stream;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.jcr.resource.api.JcrResourceConstants;
@@ -48,6 +49,12 @@ public Stream<Snippet> findAll() throws AcmException {
     }
 
     private Resource getOrCreateRoot() throws AcmException {
-        return ResourceUtils.makeFolders(resourceResolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
+        try {
+            Resource root = ResourceUtils.ensure(resourceResolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
+            resourceResolver.commit();
+            return root;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create snippet root '%s'!", ROOT), e);
+        }
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index 1c312b0e..15a1a062 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -2,12 +2,11 @@
 
 import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.AcmException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
+
+import java.util.*;
 import javax.jcr.*;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.jackrabbit.JcrConstants;
 import org.apache.sling.api.resource.*;
 import org.apache.sling.api.resource.LoginException;
 
@@ -106,17 +105,48 @@ public static void move(ResourceResolver resolver, String sourcePath, String tar
         }
     }
 
-    // TODO Value for key sling:resourceType can't be put into node: nt:folder
-    // TODO this method adds extra 'sling:resourceType' property, which is not needed for folders
-    public static Resource makeFolders(ResourceResolver resourceResolver, String path, String resourceType)
-            throws AcmException {
+    /**
+     * Creates a resource and its parents at the specified path if it does not exist.
+     * Differs to {@link ResourceUtil#getOrCreateResource} in that it does not force putting 'sling:resourceType' property.
+     */
+    public static Resource ensure(ResourceResolver resourceResolver, String path, Map<String, Object> props, String primaryType, String parentPrimaryType) throws AcmException {
         try {
-            return ResourceUtil.getOrCreateResource(resourceResolver, path, Collections.emptyMap(), resourceType, true);
-        } catch (Exception e) {
-            throw new AcmException(String.format("Folders cannot be created for path '%s'", path), e);
+            Resource resource = resourceResolver.getResource(path);
+            if (resource != null) {
+                return resource;
+            }
+
+            String[] segments = StringUtils.strip(path, "/").split("/");
+            StringBuilder currentPath = new StringBuilder();
+            Resource parent = Objects.requireNonNull(resourceResolver.getResource("/"));
+
+            for (int i = 0; i < segments.length; i++) {
+                currentPath.append("/").append(segments[i]);
+                Resource child = resourceResolver.getResource(currentPath.toString());
+                if (child == null) {
+                    Map<String, Object> allProps = new HashMap<>();
+                    if (i == segments.length - 1) {
+                        allProps.put(JcrConstants.JCR_PRIMARYTYPE, primaryType);
+                        if (props != null) {
+                            allProps.putAll(props);
+                        }
+                    } else {
+                        allProps.put(JcrConstants.JCR_PRIMARYTYPE, parentPrimaryType);
+                    }
+                    child = resourceResolver.create(parent, segments[i], allProps);
+                }
+                parent = child;
+            }
+            return parent;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create resource '%s'!", path), e);
         }
     }
 
+    public static Resource ensure(ResourceResolver resourceResolver, String path, String primaryType) throws AcmException {
+        return ensure(resourceResolver, path, Collections.emptyMap(), primaryType, primaryType);
+    }
+
     /**
      * Performs a deep, recursive copy of a resource from the specified source path to the target path.
      * <p>

From c0af82b9f737d06830d73fe6684823a0796fa5f2 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 12:48:23 +0200
Subject: [PATCH 41/83] Parent dirs more effective

---
 .../core/code/ExecutionContextOptions.java    |  1 -
 .../dev/vml/es/acm/core/code/Executor.java    |  8 +++-
 .../java/dev/vml/es/acm/core/code/Locker.java |  3 +-
 .../vml/es/acm/core/mock/MockRepository.java  |  3 +-
 .../vml/es/acm/core/repo/RepoResource.java    |  3 +-
 .../core/script/AutomaticScriptScheduler.java | 31 +++++++++----
 .../es/acm/core/script/ScriptRepository.java  |  2 -
 .../vml/es/acm/core/util/ResourceUtils.java   | 46 ++++++++++++-------
 8 files changed, 63 insertions(+), 34 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
index 0e6ceb45..17964c2e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContextOptions.java
@@ -5,7 +5,6 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
-
 import org.apache.sling.event.jobs.Job;
 
 /**
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 569ba2c1..03e36eea 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -1,5 +1,6 @@
 package dev.vml.es.acm.core.code;
 
+import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.AcmException;
 import dev.vml.es.acm.core.code.script.ContentScript;
 import dev.vml.es.acm.core.osgi.OsgiContext;
@@ -7,6 +8,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceResolverFactory;
@@ -19,7 +21,7 @@
 @Designate(ocd = Executor.Config.class)
 public class Executor {
 
-    public static final String LOCK_NAME = "executor";
+    public static final String LOCK_DIR = "executor";
 
     @ObjectClassDefinition(name = "AEM Content Manager - Code Executor")
     public @interface Config {
@@ -151,7 +153,9 @@ private ImmediateExecution executeImmediately(ExecutionContext context) {
     }
 
     private String executableLockName(ExecutionContext context) {
-        return String.format("%s/%s", context.getExecutable().getId(), LOCK_NAME);
+        return String.format(
+                "%s/%s",
+                LOCK_DIR, StringUtils.removeStart(context.getExecutable().getId(), AcmConstants.SETTINGS_ROOT + "/"));
     }
 
     public Optional<ExecutionStatus> checkStatus(String executionId) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
index 4e6121c5..e6fda595 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Locker.java
@@ -52,7 +52,8 @@ public void lock(String lockName) {
             if (name.contains("/")) {
                 String dirPath = StringUtils.substringBeforeLast(name, "/");
                 nodeName = StringUtils.substringAfterLast(name, "/");
-                dirResource = ResourceUtils.ensure(resolver, ROOT + "/" + dirPath, JcrResourceConstants.NT_SLING_FOLDER);
+                dirResource =
+                        ResourceUtils.ensure(resolver, ROOT + "/" + dirPath, JcrResourceConstants.NT_SLING_FOLDER);
             } else {
                 dirResource = ResourceUtils.ensure(resolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
                 nodeName = name;
diff --git a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
index 265b7a42..cbb53a0e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/mock/MockRepository.java
@@ -57,7 +57,8 @@ public boolean isSpecial(String id) {
 
     public Resource getOrCreateRoot() throws AcmException {
         try {
-            Resource root = ResourceUtils.ensure(resolver, ScriptType.MOCK.root(), JcrResourceConstants.NT_SLING_FOLDER);
+            Resource root =
+                    ResourceUtils.ensure(resolver, ScriptType.MOCK.root(), JcrResourceConstants.NT_SLING_FOLDER);
             resolver.commit();
             return root;
         } catch (PersistenceException e) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
index 8f194018..bcf38241 100644
--- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
+++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java
@@ -100,7 +100,8 @@ public RepoResource ensure(String primaryType) {
             try {
                 ResourceUtils.ensure(repo.getResourceResolver(), path, primaryType);
             } catch (Exception e) {
-                throw new RepoException(String.format("Cannot ensure resource '%s' at path '%s'!", primaryType, path), e);
+                throw new RepoException(
+                        String.format("Cannot ensure resource '%s' at path '%s'!", primaryType, path), e);
             }
             repo.commit(String.format("ensuring resource '%s' at path %s", primaryType, path));
             LOG.info("Ensured resource '{}' at path '{}'", primaryType, path);
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 4b050d01..eb3f6713 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -12,7 +12,6 @@
 import dev.vml.es.acm.core.repo.Repo;
 import dev.vml.es.acm.core.util.ChecksumUtils;
 import dev.vml.es.acm.core.util.ResourceUtils;
-
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
@@ -51,8 +50,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
     @ObjectClassDefinition(
             name = "AEM Content Manager - Automatic Script Scheduler",
-            description = "Schedules automatic scripts on instance up and script changes"
-    )
+            description = "Schedules automatic scripts on instance up and script changes")
     public @interface Config {
 
         @AttributeDefinition(
@@ -61,12 +59,15 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
                         "Controls who accesses the repository when scripts are automatically executed. If blank, the service user 'acm-content-service' is used.")
         String userImpersonationId();
 
-        @AttributeDefinition(name = "Health Check Interval",
+        @AttributeDefinition(
+                name = "Health Check Interval",
                 description = "Interval in milliseconds to retry health check if instance is not healthy")
         long healthRetryInterval() default 1000 * 10; // 10 seconds
 
-        @AttributeDefinition(name = "Health Check Max Count - Boot",
-                description = "Maximum number of retries after booting instance or installing package with automatic scripts")
+        @AttributeDefinition(
+                name = "Health Check Max Count - Boot",
+                description =
+                        "Maximum number of retries after booting instance or installing package with automatic scripts")
         long healthRetryMaxCountBoot() default 90; // 90 times * 10 seconds = 15 minutes
     }
 
@@ -163,7 +164,10 @@ private ScheduleResult determineSchedule(Script script, ResourceResolver resourc
     private Job bootJob() {
         return context -> {
             unscheduleScripts();
-            if (awaitInstanceHealthy("Automatic scripts queueing and scheduling", config.healthRetryMaxCountBoot(), config.healthRetryInterval())) {
+            if (awaitInstanceHealthy(
+                    "Automatic scripts queueing and scheduling",
+                    config.healthRetryMaxCountBoot(),
+                    config.healthRetryInterval())) {
                 queueAndScheduleScripts();
             }
         };
@@ -210,7 +214,10 @@ private void queueAndScheduleScripts() {
                     } else if (schedule instanceof NoneSchedule) {
                         LOG.info("Automatic script '{}' skipped due to schedule 'none'", script.getId());
                     } else {
-                        LOG.info("Automatic script '{}' skipped due to schedule '{}' (unsupported)", script.getId(), schedule.getId());
+                        LOG.info(
+                                "Automatic script '{}' skipped due to schedule '{}' (unsupported)",
+                                script.getId(),
+                                schedule.getId());
                     }
                 } else {
                     LOG.error("Automatic script schedule cannot be determined: {}", scheduleResult.getExecution());
@@ -241,7 +248,10 @@ private void scheduleCronScript(Script script, CronSchedule schedule) {
                     cronJob(script.getPath()),
                     configureScheduleOptions(script.getPath(), scheduler.EXPR(schedule.getExpression())));
             scheduled.add(script.getPath());
-            LOG.info("Cron schedule script '{}' scheduled with expression '{}'", script.getId(), schedule.getExpression());
+            LOG.info(
+                    "Cron schedule script '{}' scheduled with expression '{}'",
+                    script.getId(),
+                    schedule.getExpression());
         } else {
             LOG.error("Cron schedule script '{}' not scheduled as no expression defined!", script.getId());
         }
@@ -291,7 +301,8 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
     }
 
     private void queueScript(Script script) {
-        String userId = StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
+        String userId =
+                StringUtils.defaultIfBlank(config.userImpersonationId(), ResourceUtils.Subservice.CONTENT.userId);
         executionQueue.submit(script, new ExecutionContextOptions(ExecutionMode.RUN, userId));
     }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index 662706ef..d0e7a2f9 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -7,7 +7,6 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
-
 import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -54,6 +53,5 @@ private Resource getOrCreateRoot(ScriptType type) throws AcmException {
         } catch (PersistenceException e) {
             throw new AcmException(String.format("Cannot create script root '%s'!", type.root()), e);
         }
-
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
index 15a1a062..dc8475e1 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ResourceUtils.java
@@ -2,7 +2,6 @@
 
 import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.AcmException;
-
 import java.util.*;
 import javax.jcr.*;
 import org.apache.commons.lang3.StringUtils;
@@ -109,7 +108,13 @@ public static void move(ResourceResolver resolver, String sourcePath, String tar
      * Creates a resource and its parents at the specified path if it does not exist.
      * Differs to {@link ResourceUtil#getOrCreateResource} in that it does not force putting 'sling:resourceType' property.
      */
-    public static Resource ensure(ResourceResolver resourceResolver, String path, Map<String, Object> props, String primaryType, String parentPrimaryType) throws AcmException {
+    public static Resource ensure(
+            ResourceResolver resourceResolver,
+            String path,
+            Map<String, Object> props,
+            String primaryType,
+            String parentPrimaryType)
+            throws AcmException {
         try {
             Resource resource = resourceResolver.getResource(path);
             if (resource != null) {
@@ -118,24 +123,32 @@ public static Resource ensure(ResourceResolver resourceResolver, String path, Ma
 
             String[] segments = StringUtils.strip(path, "/").split("/");
             StringBuilder currentPath = new StringBuilder();
-            Resource parent = Objects.requireNonNull(resourceResolver.getResource("/"));
+            Resource parent = null;
+            int startIdx = 0;
 
+            // Find the deepest existing parent
             for (int i = 0; i < segments.length; i++) {
                 currentPath.append("/").append(segments[i]);
-                Resource child = resourceResolver.getResource(currentPath.toString());
-                if (child == null) {
-                    Map<String, Object> allProps = new HashMap<>();
-                    if (i == segments.length - 1) {
-                        allProps.put(JcrConstants.JCR_PRIMARYTYPE, primaryType);
-                        if (props != null) {
-                            allProps.putAll(props);
-                        }
-                    } else {
-                        allProps.put(JcrConstants.JCR_PRIMARYTYPE, parentPrimaryType);
+                Resource existing = resourceResolver.getResource(currentPath.toString());
+                if (existing == null) {
+                    break;
+                }
+                parent = existing;
+                startIdx = i + 1;
+            }
+
+            // Create missing nodes from the deepest existing parent
+            for (int i = startIdx; i < segments.length; i++) {
+                Map<String, Object> allProps = new HashMap<>();
+                if (i == segments.length - 1) {
+                    allProps.put(JcrConstants.JCR_PRIMARYTYPE, primaryType);
+                    if (props != null) {
+                        allProps.putAll(props);
                     }
-                    child = resourceResolver.create(parent, segments[i], allProps);
+                } else {
+                    allProps.put(JcrConstants.JCR_PRIMARYTYPE, parentPrimaryType);
                 }
-                parent = child;
+                parent = resourceResolver.create(parent, segments[i], allProps);
             }
             return parent;
         } catch (PersistenceException e) {
@@ -143,7 +156,8 @@ public static Resource ensure(ResourceResolver resourceResolver, String path, Ma
         }
     }
 
-    public static Resource ensure(ResourceResolver resourceResolver, String path, String primaryType) throws AcmException {
+    public static Resource ensure(ResourceResolver resourceResolver, String path, String primaryType)
+            throws AcmException {
         return ensure(resourceResolver, path, Collections.emptyMap(), primaryType, primaryType);
     }
 

From 2f1438c80d9c7e9b998b3b6bf85fceb6d4c7b35c Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 13:19:03 +0200
Subject: [PATCH 42/83] Safe script execution

---
 .../script/manual/example/ACME-202_page-thumbnail.groovy | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy
index 3dc89da5..afbb4465 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy
@@ -17,10 +17,7 @@ boolean canRun() {
 
 void doRun() {
     def page = repo.get(arguments.value("pagePath"))
-
-    def pageThumbnails = page.child("jcr:content/image/file/jcr:content/dam:thumbnails")
-    pageThumbnails.delete()
-
-    def pageThumbnail = page.child("jcr:content/image/file")
-    pageThumbnail.saveFile("image/jpeg", arguments.value("pageThumbnailFile"))
+    def pageImage = page.child("jcr:content/image").ensure("nt:unstructured")
+    pageImage.child("file/jcr:content/dam:thumbnails").delete()
+    pageImage.child("file").saveFile("image/jpeg", arguments.value("pageThumbnailFile"))
 }
\ No newline at end of file

From 9d1b67ffe3f9629a2f1812ccd1be7112021efa50 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 13:42:20 +0200
Subject: [PATCH 43/83] Safe script execution

---
 core/src/main/java/dev/vml/es/acm/core/script/Script.java | 2 +-
 ui.frontend/src/components/ScriptAutomaticList.tsx        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
index 8ddb426b..6318efc7 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java
@@ -67,7 +67,7 @@ public String getPath() {
     }
 
     public ScriptType getType() {
-        return ScriptType.byPath(getPath()).orElse(ScriptType.AUTOMATIC);
+        return ScriptType.byPath(getPath()).orElse(ScriptType.MANUAL);
     }
 
     public InputStream readContent() throws ScriptException {
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index 14fdb481..9234edc5 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -45,7 +45,7 @@ const ScriptAutomaticList: React.FC = () => {
               <Button
                   variant="negative"
                   isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
-                  onPress={() => window.open(`${instancePrefix}/system/console/configMgr/\tdev.vml.es.acm.core.script.AutomaticScriptScheduler`, '_blank')}
+                  onPress={() => window.open(`${instancePrefix}/system/console/configMgr/dev.vml.es.acm.core.script.AutomaticScriptScheduler`, '_blank')}
               >
                 <Settings />
                 <Text>Configure</Text>

From 35ad07992bf04aa0e145eb858c53dd67bf210f7b Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 13 Aug 2025 13:55:28 +0200
Subject: [PATCH 44/83] Groovy upgrade

---
 core/pom.xml |  2 --
 pom.xml      | 11 +++++++++++
 2 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/core/pom.xml b/core/pom.xml
index 305864af..004f7a16 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -313,12 +313,10 @@ DynamicImport-Package: *
         <dependency>
             <groupId>org.apache.groovy</groupId>
             <artifactId>groovy</artifactId>
-            <version>4.0.18</version>
         </dependency>
         <dependency>
             <groupId>org.apache.groovy</groupId>
             <artifactId>groovy-templates</artifactId>
-            <version>4.0.9</version>
         </dependency>
 
         <!-- Testing -->
diff --git a/pom.xml b/pom.xml
index 88c0a5be..425aa90a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -838,6 +838,17 @@ Private-Package: com.fasterxml.jackson.datatype.jsr310
                 <version>5.7.4</version>
                 <scope>provided</scope>
             </dependency>
+            <!-- Groovy -->
+            <dependency>
+                <groupId>org.apache.groovy</groupId>
+                <artifactId>groovy</artifactId>
+                <version>4.0.23</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.groovy</groupId>
+                <artifactId>groovy-templates</artifactId>
+                <version>4.0.28</version>
+            </dependency>
             <!-- Testing -->
             <dependency>
                 <groupId>org.junit</groupId>

From fd2949afb4e642855b55c298c32a78cb53142593 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 10:25:12 +0200
Subject: [PATCH 45/83] Execution query simplified

---
 .../vml/es/acm/core/code/ExecutableUtils.java | 11 +++++++++
 .../vml/es/acm/core/code/ExecutionQuery.java  | 23 ++++++-------------
 ui.frontend/src/pages/HistoryPage.tsx         | 15 ++----------
 ui.frontend/src/utils/api.types.ts            |  4 ----
 4 files changed, 20 insertions(+), 33 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
index cc23e2a3..8c6898f8 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
@@ -3,6 +3,7 @@
 import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.script.ScriptRepository;
 import dev.vml.es.acm.core.script.ScriptType;
+import dev.vml.es.acm.core.util.ResourceUtils;
 import org.apache.commons.lang3.StringUtils;
 
 public final class ExecutableUtils {
@@ -27,4 +28,14 @@ public static String nameById(String id) {
         }
         return String.format("Executable '%s'", StringUtils.removeStart(id, AcmConstants.SETTINGS_ROOT + "/"));
     }
+
+    public static boolean isIdExplicit(String id) {
+        return Executable.ID_CONSOLE.equals(id) || StringUtils.startsWith(id, ScriptRepository.ROOT + "/");
+    }
+
+    public static boolean isUserExplicit(String userId) {
+        return ResourceUtils.Subservice.CONTENT.userId.equals(userId)
+                || StringUtils.equals(userId, "admin")
+                || StringUtils.containsAny(userId, "@.-");
+    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java
index dac8136f..9e9d44c5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQuery.java
@@ -11,8 +11,6 @@
 
 public class ExecutionQuery {
 
-    private static final String WILDCARD = "@W@";
-
     private String path = ExecutionHistory.ROOT;
 
     private String executableId;
@@ -27,13 +25,6 @@ public class ExecutionQuery {
 
     private Range<Integer> duration;
 
-    // TODO do more escaping e.g. for JCR illegal characters
-    // TODO beware that e.g. Text.escapeIllegalJcrChars is too aggressive for paths
-    public static String escape(String param) {
-        String escaped = StringUtils.replace(param, "%", "%%");
-        return StringUtils.replace(escaped, WILDCARD, "%");
-    }
-
     public static ExecutionQuery from(SlingHttpServletRequest request) {
         ExecutionQuery result = new ExecutionQuery();
         result.setExecutableId(ServletUtils.stringParam(request, "executableId"));
@@ -114,20 +105,20 @@ protected String toSql() {
         filters.add(String.format(
                 "s.[%s] = '%s'", JcrResourceConstants.SLING_RESOURCE_TYPE_PROPERTY, HistoricalExecution.RESOURCE_TYPE));
         if (path != null) {
-            filters.add(String.format("ISDESCENDANTNODE(s, '%s')", escape(path)));
+            filters.add(String.format("ISDESCENDANTNODE(s, '%s')", path));
         }
         if (executableId != null) {
-            if (StringUtils.contains(executableId, WILDCARD)) {
-                filters.add(String.format("s.[executableId] LIKE '%s'", escape(executableId)));
+            if (ExecutableUtils.isIdExplicit(executableId)) {
+                filters.add(String.format("s.[executableId] = '%s'", executableId));
             } else {
-                filters.add(String.format("s.[executableId] = '%s'", escape(executableId)));
+                filters.add(String.format("s.[executableId] LIKE '%%%s%%'", executableId));
             }
         }
         if (userId != null) {
-            if (StringUtils.contains(userId, WILDCARD)) {
-                filters.add(String.format("s.[userId] LIKE '%s'", escape(userId)));
+            if (ExecutableUtils.isUserExplicit(userId)) {
+                filters.add(String.format("s.[userId] = '%s'", userId));
             } else {
-                filters.add(String.format("s.[userId] = '%s'", escape(userId)));
+                filters.add(String.format("s.[userId] LIKE '%%%s%%'", userId));
             }
         }
         if (CollectionUtils.isNotEmpty(statuses)) {
diff --git a/ui.frontend/src/pages/HistoryPage.tsx b/ui.frontend/src/pages/HistoryPage.tsx
index 5fb4ede6..e3203d30 100644
--- a/ui.frontend/src/pages/HistoryPage.tsx
+++ b/ui.frontend/src/pages/HistoryPage.tsx
@@ -19,7 +19,7 @@ import ExecutionStatusBadge from '../components/ExecutionStatusBadge';
 import UserInfo from '../components/UserInfo';
 import { useFormatter } from '../hooks/formatter';
 import { toastRequest } from '../utils/api';
-import { ExecutionFormat, ExecutionOutput, ExecutionQueryParams, ExecutionStatus, ExecutionSummary, isExecutableExplicit } from '../utils/api.types';
+import { ExecutionFormat, ExecutionOutput, ExecutionQueryParams, ExecutionStatus, ExecutionSummary } from '../utils/api.types';
 import { Dates } from '../utils/dates';
 import { Urls } from '../utils/url';
 
@@ -66,18 +66,7 @@ const HistoryPage = () => {
 
           const response = await toastRequest<ExecutionOutput<ExecutionSummary>>({
             method: 'GET',
-            url: Urls.compose(
-              '/apps/acm/api/execution.json',
-              (() => {
-                const result = new URLSearchParams(params);
-                const wildcard = '@W@';
-                result.delete(ExecutionQueryParams.EXECUTABLE_ID);
-                result.append(ExecutionQueryParams.EXECUTABLE_ID, isExecutableExplicit(executableId) ? executableId : `${wildcard}${executableId}${wildcard}`);
-                result.delete(ExecutionQueryParams.USER_ID);
-                result.append(ExecutionQueryParams.USER_ID, `${wildcard}${userId}${wildcard}`);
-                return result;
-              })(),
-            ),
+            url: Urls.compose('/apps/acm/api/execution.json', params),
             operation: `Executions loading`,
             positive: false,
           });
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 0c8fb132..65a4d670 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -28,10 +28,6 @@ export function isExecutableScript(id: string): boolean {
   return id.startsWith(ScriptRootPath);
 }
 
-export function isExecutableExplicit(id: string): boolean {
-  return isExecutableConsole(id) || isExecutableScript(id);
-}
-
 export type Description = {
   execution: Execution;
   arguments: {

From 69a3479413ddad15297001cc005f6069a45e7cb1 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 11:07:46 +0200
Subject: [PATCH 46/83] Execution query limit param

---
 .../es/acm/core/servlet/ExecutionServlet.java | 23 ++++++++++++++-----
 ui.frontend/src/pages/HistoryPage.tsx         | 12 ++++++++--
 ui.frontend/src/utils/api.types.ts            |  1 +
 3 files changed, 28 insertions(+), 8 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
index 9f3bdfc3..f3d67fa8 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
@@ -7,6 +7,7 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.servlet.Servlet;
 import org.apache.commons.lang3.BooleanUtils;
 import org.apache.sling.api.SlingHttpServletRequest;
@@ -36,6 +37,8 @@ public class ExecutionServlet extends SlingAllMethodsServlet {
 
     private static final String FORMAT_PARAM = "format";
 
+    private static final String LIMIT_PARAM = "limit";
+
     private static final String QUEUED_PARAM = "queued";
 
     @Reference
@@ -50,32 +53,40 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
             return;
         }
 
+        Stream<?> executionStream;
         try {
             ExecutionHistory executionHistory = new ExecutionHistory(request.getResourceResolver());
-            List<?> executions;
 
             List<String> ids = stringsParam(request, ID_PARAM);
             if (ids != null) {
                 ExecutionResolver executionResolver = new ExecutionResolver(queue, request.getResourceResolver());
                 if (format == ExecutionFormat.FULL) {
-                    executions = executionResolver.readAll(ids).collect(Collectors.toList());
+                    executionStream = executionResolver.readAll(ids);
                 } else {
-                    executions = executionResolver.readAllSummaries(ids).collect(Collectors.toList());
+                    executionStream = executionResolver.readAllSummaries(ids);
                 }
             } else {
                 ExecutionQuery criteria = ExecutionQuery.from(request);
                 if (format == ExecutionFormat.FULL) {
-                    executions = executionHistory.findAll(criteria).collect(Collectors.toList());
+                    executionStream = executionHistory.findAll(criteria);
                 } else {
                     Boolean queued = boolParam(request, QUEUED_PARAM);
                     if (BooleanUtils.isTrue(queued)) {
-                        executions = queue.findAllSummaries().collect(Collectors.toList());
+                        executionStream = queue.findAllSummaries();
                     } else {
-                        executions = executionHistory.findAllSummaries(criteria).collect(Collectors.toList());
+                        executionStream = executionHistory.findAllSummaries(criteria);
                     }
                 }
             }
 
+            Integer limit = intParam(request, LIMIT_PARAM);
+            List<?> executions;
+            if (limit != null) {
+                executions = executionStream.limit(limit).collect(Collectors.toList());
+            } else {
+                executions = executionStream.collect(Collectors.toList());
+            }
+
             ExecutionOutput output = new ExecutionOutput(executions);
             respondJson(response, ok("Executions listed successfully", output));
         } catch (Exception e) {
diff --git a/ui.frontend/src/pages/HistoryPage.tsx b/ui.frontend/src/pages/HistoryPage.tsx
index e3203d30..7e9a38ab 100644
--- a/ui.frontend/src/pages/HistoryPage.tsx
+++ b/ui.frontend/src/pages/HistoryPage.tsx
@@ -33,9 +33,11 @@ const HistoryPage = () => {
   const [searchState, setSearchState] = useSearchParams();
   const [startDate, setStartDate] = useState<DateValue | null>(Dates.toCalendarDateTimeOrNull(searchState.get(ExecutionQueryParams.START_DATE)) ?? Dates.toCalendarDateTime(Dates.daysAgoAtMidnight(7)));
   const [endDate, setEndDate] = useState<DateValue | null>(Dates.toCalendarDateTimeOrNull(searchState.get(ExecutionQueryParams.END_DATE)));
-  const [status, setStatus] = useState<string | null>(searchState.get(ExecutionQueryParams.STATUS) || 'all');
   const [executableId, setExecutableId] = useState<string>(searchState.get(ExecutionQueryParams.EXECUTABLE_ID) || '');
   const [userId, setUserId] = useState<string>(searchState.get(ExecutionQueryParams.USER_ID) || '');
+  const [status, setStatus] = useState<string | null>(searchState.get(ExecutionQueryParams.STATUS) || 'all');
+  const limitOptions = [10, 30, 60, 100, 200, 300];
+  const [limit, setLimit] = useState<number>(Number(searchState.get(ExecutionQueryParams.LIMIT)) || 30);
 
   const [durationMinInitial, durationMaxInitial] = (() => {
     const searchParam = searchState.get(ExecutionQueryParams.DURATION);
@@ -55,6 +57,7 @@ const HistoryPage = () => {
         try {
           const params = new URLSearchParams();
           params.append(ExecutionQueryParams.FORMAT, ExecutionFormat.SUMMARY.toLowerCase());
+          params.append(ExecutionQueryParams.LIMIT, String(limit));
           if (executableId) params.append(ExecutionQueryParams.EXECUTABLE_ID, executableId);
           if (userId) params.append(ExecutionQueryParams.USER_ID, userId);
           if (startDate) params.append(ExecutionQueryParams.START_DATE, startDate.toString());
@@ -85,7 +88,7 @@ const HistoryPage = () => {
       fetchExecutions();
     },
     HistoryFilterDelay,
-    [startDate, endDate, status, executableId, userId, durationMin, durationMax],
+    [limit, startDate, endDate, status, executableId, userId, durationMin, durationMax],
   );
 
   const renderEmptyState = () => (
@@ -127,6 +130,11 @@ const HistoryPage = () => {
               <Text>Succeeded</Text>
             </Item>
           </Picker>
+          <Picker width="size-1000" label="Limit" selectedKey={String(limit)} onSelectionChange={(key) => setLimit(Number(key))}>
+            {limitOptions.map((value) => (
+                <Item key={String(value)}><Text>{value}</Text></Item>
+            ))}
+          </Picker>
         </Flex>
       </View>
       {executions === null || loading ? (
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 65a4d670..4132589e 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -369,6 +369,7 @@ export enum ExecutionFormat {
 
 export enum ExecutionQueryParams {
   FORMAT = 'format',
+  LIMIT = 'limit',
   START_DATE = 'startDate',
   END_DATE = 'endDate',
   STATUS = 'status',

From a2592b4c825c2bc6f84ebef5786b53eddf3ebb3e Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 11:57:21 +0200
Subject: [PATCH 47/83] More fail-safe limits

---
 .../java/dev/vml/es/acm/core/gui/SpaSettings.java  |  2 +-
 .../vml/es/acm/core/servlet/ExecutionServlet.java  | 14 +++++++++-----
 ui.frontend/src/App.tsx                            |  2 +-
 ui.frontend/src/pages/HistoryPage.tsx              |  8 ++++----
 4 files changed, 15 insertions(+), 11 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
index 71dedbd4..df420582 100644
--- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
+++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
@@ -55,6 +55,6 @@ public long getScriptStatsLimit() {
                 name = "Script Stats Limit",
                 description =
                         "Limit for the number of historical executions to be considered to calculate the average duration.")
-        long scriptStatsLimit() default 30;
+        long scriptStatsLimit() default 20;
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
index f3d67fa8..fb126a86 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecutionServlet.java
@@ -39,6 +39,8 @@ public class ExecutionServlet extends SlingAllMethodsServlet {
 
     private static final String LIMIT_PARAM = "limit";
 
+    private static final String OFFSET_PARAM = "offset";
+
     private static final String QUEUED_PARAM = "queued";
 
     @Reference
@@ -79,13 +81,15 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
                 }
             }
 
+            Integer offset = intParam(request, OFFSET_PARAM);
+            if (offset != null && offset > 0) {
+                executionStream = executionStream.skip(offset);
+            }
             Integer limit = intParam(request, LIMIT_PARAM);
-            List<?> executions;
-            if (limit != null) {
-                executions = executionStream.limit(limit).collect(Collectors.toList());
-            } else {
-                executions = executionStream.collect(Collectors.toList());
+            if (limit != null && limit > 0) {
+                executionStream = executionStream.limit(limit);
             }
+            List<?> executions = executionStream.collect(Collectors.toList());
 
             ExecutionOutput output = new ExecutionOutput(executions);
             respondJson(response, ok("Executions listed successfully", output));
diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx
index 545869db..680ccbab 100644
--- a/ui.frontend/src/App.tsx
+++ b/ui.frontend/src/App.tsx
@@ -17,7 +17,7 @@ function App() {
     spaSettings: {
       appStateInterval: 3000,
       executionPollInterval: 1400,
-      scriptStatsLimit: 30,
+      scriptStatsLimit: 20,
     },
     healthStatus: {
       healthy: true,
diff --git a/ui.frontend/src/pages/HistoryPage.tsx b/ui.frontend/src/pages/HistoryPage.tsx
index 7e9a38ab..9e09d0d0 100644
--- a/ui.frontend/src/pages/HistoryPage.tsx
+++ b/ui.frontend/src/pages/HistoryPage.tsx
@@ -24,6 +24,7 @@ import { Dates } from '../utils/dates';
 import { Urls } from '../utils/url';
 
 const HistoryFilterDelay = 1500;
+const HistoryLimitOptions = [10, 20, 30, 50, 100, 200, 500];
 
 const HistoryPage = () => {
   const navigate = useNavigate();
@@ -31,13 +32,12 @@ const HistoryPage = () => {
   const [loading, setLoading] = useState<boolean>(false);
 
   const [searchState, setSearchState] = useSearchParams();
-  const [startDate, setStartDate] = useState<DateValue | null>(Dates.toCalendarDateTimeOrNull(searchState.get(ExecutionQueryParams.START_DATE)) ?? Dates.toCalendarDateTime(Dates.daysAgoAtMidnight(7)));
+  const [startDate, setStartDate] = useState<DateValue | null>(Dates.toCalendarDateTimeOrNull(searchState.get(ExecutionQueryParams.START_DATE)));
   const [endDate, setEndDate] = useState<DateValue | null>(Dates.toCalendarDateTimeOrNull(searchState.get(ExecutionQueryParams.END_DATE)));
   const [executableId, setExecutableId] = useState<string>(searchState.get(ExecutionQueryParams.EXECUTABLE_ID) || '');
   const [userId, setUserId] = useState<string>(searchState.get(ExecutionQueryParams.USER_ID) || '');
   const [status, setStatus] = useState<string | null>(searchState.get(ExecutionQueryParams.STATUS) || 'all');
-  const limitOptions = [10, 30, 60, 100, 200, 300];
-  const [limit, setLimit] = useState<number>(Number(searchState.get(ExecutionQueryParams.LIMIT)) || 30);
+  const [limit, setLimit] = useState<number>(Number(searchState.get(ExecutionQueryParams.LIMIT)) || HistoryLimitOptions[0]);
 
   const [durationMinInitial, durationMaxInitial] = (() => {
     const searchParam = searchState.get(ExecutionQueryParams.DURATION);
@@ -131,7 +131,7 @@ const HistoryPage = () => {
             </Item>
           </Picker>
           <Picker width="size-1000" label="Limit" selectedKey={String(limit)} onSelectionChange={(key) => setLimit(Number(key))}>
-            {limitOptions.map((value) => (
+            {HistoryLimitOptions.map((value) => (
                 <Item key={String(value)}><Text>{value}</Text></Item>
             ))}
           </Picker>

From 16829513a475ba79ae0c356f1dd5cd2ea5bcd689 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 12:04:49 +0200
Subject: [PATCH 48/83] Ignore package in scripts (added sometimes by IDE)

---
 .../java/dev/vml/es/acm/core/code/script/ScriptUtils.java    | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
index c2c32642..8ebc4bd6 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
@@ -3,6 +3,7 @@
 import groovy.lang.Binding;
 import groovy.lang.GroovyShell;
 import java.util.List;
+import org.apache.commons.lang3.StringUtils;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.MethodNode;
@@ -51,7 +52,9 @@ public static boolean isMethodValid(ClassNode mainClass, String methodName, Stri
     public static ClassNode requireMainClass(List<ClassNode> classes, String mainClassName) {
         ClassNode mainClass = null;
         for (ClassNode classNode : classes) {
-            if (classNode != null && mainClassName.equals(classNode.getName())) {
+            if (classNode != null
+                    && (StringUtils.equals(classNode.getName(), mainClassName)
+                            || StringUtils.endsWith(classNode.getName(), "." + mainClassName))) {
                 mainClass = classNode;
                 break;
             }

From 202ff86802d598014abae7e38edacc467ec3698a Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 12:39:05 +0200
Subject: [PATCH 49/83] Queue max size

---
 .../vml/es/acm/core/code/ExecutionQueue.java  | 24 ++++++++++++++++++-
 .../core/script/AutomaticScriptScheduler.java | 22 ++++++++++++-----
 2 files changed, 39 insertions(+), 7 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index da871484..6492ba58 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -38,7 +38,10 @@ public class ExecutionQueue implements JobExecutor {
     @ObjectClassDefinition(name = "AEM Content Manager - Execution Queue")
     public @interface Config {
 
-        @AttributeDefinition(name = "Async Poll Interval")
+        @AttributeDefinition(name = "Max Size", description = "Prevents overloading the system.")
+        long maxSize() default 10;
+
+        @AttributeDefinition(name = "Async Poll Interval", description = "Interval in milliseconds to poll for job status.")
         long asyncPollInterval() default 500L;
     }
 
@@ -70,6 +73,13 @@ protected void deactivate() {
     }
 
     public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
+        int currentSize = getCurrentSize();
+        if (currentSize >= getMaxSize()) {
+            throw new AcmException(String.format(
+                    "Execution queue is full (%d/%d), cannot submit executable '%s'!",
+                    currentSize, config.maxSize(), executable.getId()));
+        }
+
         Map<String, Object> jobProps = new HashMap<>();
         jobProps.putAll(ExecutionContextOptions.toJobProps(contextOptions));
         jobProps.putAll(Code.toJobProps(executable));
@@ -106,6 +116,18 @@ private Stream<Job> findJobs() {
         return jobManager.findJobs(JobManager.QueryType.ALL, TOPIC, -1, Collections.emptyMap()).stream();
     }
 
+    public int getCurrentSize() {
+        return (int) findJobs().count();
+    }
+
+    public int getMaxSize() {
+        return (int) config.maxSize();
+    }
+
+    public boolean isFull() {
+        return getCurrentSize() >= getMaxSize();
+    }
+
     public Stream<Execution> readAll(Collection<String> executionIds) throws AcmException {
         return executionIds.stream()
                 .filter(StringUtils::isNotBlank)
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index eb3f6713..10bab10a 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -284,18 +284,28 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
                 LOG.info("Script '{}' already locked!", script.getPath());
                 return false;
             }
-            Execution executionQueued = executionQueue
+
+            int queueCurrentSize = executionQueue.getCurrentSize();
+            int queueMaxSize = executionQueue.getMaxSize();
+            if (queueCurrentSize >= queueMaxSize) {
+                LOG.info("Script '{}' not queued because queue is full ({}/{})!", script.getPath(), queueCurrentSize, queueMaxSize);
+                return false;
+            }
+
+            Execution queued = executionQueue
                     .findByExecutableId(context.getExecutable().getId())
                     .orElse(null);
-            if (executionQueued != null) {
-                LOG.info("Script '{}' already queued: {}", script.getPath(), executionQueued);
+            if (queued != null) {
+                LOG.info("Script '{}' already queued: {}", script.getPath(), queued);
                 return false;
             }
-            Execution executionChecking = executor.check(context);
-            if (executionChecking.getStatus() != ExecutionStatus.SUCCEEDED) {
-                LOG.info("Script '{}' checking not succeeded: {}", script.getPath(), executionChecking);
+
+            Execution checking = executor.check(context);
+            if (checking.getStatus() != ExecutionStatus.SUCCEEDED) {
+                LOG.info("Script '{}' checking not succeeded: {}", script.getPath(), checking);
                 return false;
             }
+
             return true;
         }
     }

From 49f18ec0aa119015e76da24262c3818b0fd044fa Mon Sep 17 00:00:00 2001
From: Krystian Panek
 <81212505+krystian-panek-vmltech@users.noreply.github.com>
Date: Thu, 14 Aug 2025 12:41:13 +0200
Subject: [PATCH 50/83] Update
 core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
 core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 6492ba58..1a521050 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -77,7 +77,7 @@ public Execution submit(Executable executable, ExecutionContextOptions contextOp
         if (currentSize >= getMaxSize()) {
             throw new AcmException(String.format(
                     "Execution queue is full (%d/%d), cannot submit executable '%s'!",
-                    currentSize, config.maxSize(), executable.getId()));
+                    currentSize, getMaxSize(), executable.getId()));
         }
 
         Map<String, Object> jobProps = new HashMap<>();

From 1953a44129e34cc9dc9d8d620094b20aab2efcec Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 13:08:29 +0200
Subject: [PATCH 51/83] Execution queue max size

---
 .../vml/es/acm/core/code/ExecutionQueue.java  |  5 ++++-
 .../dev/vml/es/acm/core/gui/SpaSettings.java  |  2 +-
 ui.frontend/src/components/ScriptExecutor.tsx | 20 ++++++++++---------
 ui.frontend/src/utils/api.types.ts            |  2 +-
 4 files changed, 17 insertions(+), 12 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 6492ba58..7bc164d6 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -19,6 +19,7 @@
 import org.apache.sling.event.jobs.consumer.JobExecutor;
 import org.osgi.service.component.annotations.*;
 import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
 import org.osgi.service.metatype.annotations.ObjectClassDefinition;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -26,7 +27,9 @@
 @Component(
         immediate = true,
         service = {ExecutionQueue.class, JobExecutor.class},
-        property = {JobExecutor.PROPERTY_TOPICS + "=" + ExecutionQueue.TOPIC})
+        property = {JobExecutor.PROPERTY_TOPICS + "=" + ExecutionQueue.TOPIC}
+)
+@Designate(ocd = ExecutionQueue.Config.class)
 public class ExecutionQueue implements JobExecutor {
 
     public static final String NAME = "AEM Content Manager Execution Queue";
diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
index df420582..74e041d0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
+++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
@@ -55,6 +55,6 @@ public long getScriptStatsLimit() {
                 name = "Script Stats Limit",
                 description =
                         "Limit for the number of historical executions to be considered to calculate the average duration.")
-        long scriptStatsLimit() default 20;
+        long scriptStatsLimit() default 10;
     }
 }
diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/ScriptExecutor.tsx
index e7cce100..6fc75824 100644
--- a/ui.frontend/src/components/ScriptExecutor.tsx
+++ b/ui.frontend/src/components/ScriptExecutor.tsx
@@ -1,9 +1,9 @@
 import { Button, ButtonGroup, Cell, Column, Content, Dialog, DialogTrigger, Divider, Flex, Heading, IllustratedMessage, Item, Menu, MenuTrigger, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import { Key, Selection } from '@react-types/shared';
 import NoSearchResults from '@spectrum-icons/illustrations/NoSearchResults';
-import ApplicationDelivery from '@spectrum-icons/workflow/ApplicationDelivery';
 import Cancel from '@spectrum-icons/workflow/Cancel';
 import Checkmark from '@spectrum-icons/workflow/Checkmark';
+import ApplicationDelivery from '@spectrum-icons/workflow/ApplicationDelivery';
 import Clock from '@spectrum-icons/workflow/Clock';
 import Close from '@spectrum-icons/workflow/Close';
 import Code from '@spectrum-icons/workflow/Code';
@@ -14,8 +14,7 @@ import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app.ts';
 import { apiRequest } from '../utils/api.ts';
-import { ExecutionOutput, ExecutionSummary, InstanceType } from '../utils/api.types.ts';
-import { isProduction } from '../utils/node';
+import {ExecutionOutput, ExecutionSummary, instancePrefix, InstanceType} from '../utils/api.types.ts';
 import { intervalToTimeout } from '../utils/spectrum.ts';
 import DateExplained from './DateExplained';
 import ExecutableIdValue from './ExecutableIdValue';
@@ -25,7 +24,6 @@ import UserInfo from './UserInfo';
 
 const ScriptExecutor = () => {
   const appState = useAppState();
-  const prefix = isProduction() ? '' : 'http://localhost:4502';
   const navigate = useNavigate();
 
   const [executions, setExecutions] = useState<ExecutionSummary[]>([]);
@@ -86,15 +84,19 @@ const ScriptExecutor = () => {
                   <Settings />
                   <Text>Configure</Text>
                 </Button>
-                <Menu onAction={(pid) => window.open(`${prefix}/system/console/configMgr/${pid}`, '_blank')}>
-                  <Item key="org.apache.sling.event.jobs.QueueConfiguration~acmexecutionqueue">
-                    <Clock />
-                    <Text>Execution Queue</Text>
-                  </Item>
+                <Menu onAction={(pid) => window.open(`${instancePrefix}/system/console/configMgr/${pid}`, '_blank')}>
                   <Item key="dev.vml.es.acm.core.code.Executor">
                     <Code />
                     <Text>Code Executor</Text>
                   </Item>
+                  <Item key="dev.vml.es.acm.core.code.ExecutionQueue">
+                    <Clock />
+                    <Text>Execution Queue</Text>
+                  </Item>
+                  <Item key="org.apache.sling.event.jobs.QueueConfiguration~acmexecutionqueue">
+                    <ApplicationDelivery />
+                    <Text>Execution Queue &mdash; Sling</Text>
+                  </Item>
                 </Menu>
               </MenuTrigger>
             </ButtonGroup>
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 4132589e..e6af6b99 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -340,7 +340,7 @@ export enum InstanceType {
   CLOUD_CONTAINER = 'CLOUD_CONTAINER',
 }
 
-export const instancePrefix = isProduction() ? '' : 'http://localhost:4502';
+export const instancePrefix = isProduction() ? '' : 'http://localhost:5502';
 
 export type HealthStatus = {
   healthy: boolean;

From 55530fde6f85e37496a3602d9d16fd4656942d6a Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 14:02:38 +0200
Subject: [PATCH 52/83] Execution queue reset button

---
 .../vml/es/acm/core/code/ExecutionQueue.java  | 34 +++++----
 .../core/script/AutomaticScriptScheduler.java | 10 ++-
 .../es/acm/core/servlet/QueueCodeServlet.java | 46 ++++++++----
 .../src/components/ExecutionsResetButton.tsx  | 70 +++++++++++++++++++
 .../src/components/ScriptAutomaticList.tsx    | 18 ++---
 ui.frontend/src/components/ScriptExecutor.tsx |  6 +-
 ui.frontend/src/pages/HistoryPage.tsx         |  4 +-
 ui.frontend/src/pages/ScriptsPage.tsx         |  2 +-
 8 files changed, 145 insertions(+), 45 deletions(-)
 create mode 100644 ui.frontend/src/components/ExecutionsResetButton.tsx

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 0864d0ee..23d0b8da 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -2,6 +2,7 @@
 
 import dev.vml.es.acm.core.AcmException;
 import dev.vml.es.acm.core.util.ResourceUtils;
+import dev.vml.es.acm.core.util.StreamUtils;
 import java.util.*;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutorService;
@@ -14,6 +15,7 @@
 import org.apache.sling.api.resource.ResourceResolverFactory;
 import org.apache.sling.event.jobs.Job;
 import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.TopicStatistics;
 import org.apache.sling.event.jobs.consumer.JobExecutionContext;
 import org.apache.sling.event.jobs.consumer.JobExecutionResult;
 import org.apache.sling.event.jobs.consumer.JobExecutor;
@@ -27,13 +29,10 @@
 @Component(
         immediate = true,
         service = {ExecutionQueue.class, JobExecutor.class},
-        property = {JobExecutor.PROPERTY_TOPICS + "=" + ExecutionQueue.TOPIC}
-)
+        property = {JobExecutor.PROPERTY_TOPICS + "=" + ExecutionQueue.TOPIC})
 @Designate(ocd = ExecutionQueue.Config.class)
 public class ExecutionQueue implements JobExecutor {
 
-    public static final String NAME = "AEM Content Manager Execution Queue";
-
     public static final String TOPIC = "dev/vml/es/acm/ExecutionQueue";
 
     private static final Logger LOG = LoggerFactory.getLogger(ExecutionQueue.class);
@@ -44,7 +43,9 @@ public class ExecutionQueue implements JobExecutor {
         @AttributeDefinition(name = "Max Size", description = "Prevents overloading the system.")
         long maxSize() default 10;
 
-        @AttributeDefinition(name = "Async Poll Interval", description = "Interval in milliseconds to poll for job status.")
+        @AttributeDefinition(
+                name = "Async Poll Interval",
+                description = "Interval in milliseconds to poll for job status.")
         long asyncPollInterval() default 500L;
     }
 
@@ -76,7 +77,7 @@ protected void deactivate() {
     }
 
     public Execution submit(Executable executable, ExecutionContextOptions contextOptions) throws AcmException {
-        int currentSize = getCurrentSize();
+        long currentSize = getCurrentSize();
         if (currentSize >= getMaxSize()) {
             throw new AcmException(String.format(
                     "Execution queue is full (%d/%d), cannot submit executable '%s'!",
@@ -119,12 +120,20 @@ private Stream<Job> findJobs() {
         return jobManager.findJobs(JobManager.QueryType.ALL, TOPIC, -1, Collections.emptyMap()).stream();
     }
 
-    public int getCurrentSize() {
-        return (int) findJobs().count();
+    // TODO use statistics to speed it up
+    public long getCurrentSize() {
+        return findJobs().count();
+    }
+
+    public TopicStatistics getStatistics() {
+        return StreamUtils.asStream(jobManager.getTopicStatistics().iterator())
+                .filter(ts -> TOPIC.equals(ts.getTopic()))
+                .findFirst()
+                .orElseThrow(() -> new AcmException(String.format("Cannot find topic statistics for topic '%s'!", TOPIC)));
     }
 
-    public int getMaxSize() {
-        return (int) config.maxSize();
+    public long getMaxSize() {
+        return config.maxSize();
     }
 
     public boolean isFull() {
@@ -231,13 +240,14 @@ private Execution executeAsync(ExecutionContextOptions contextOptions, QueuedExe
         }
     }
 
-    // TODO introduce a button using it
     public void reset() {
         if (jobAsyncExecutor != null && !jobAsyncExecutor.isShutdown()) {
             jobAsyncExecutor.shutdownNow();
         }
         jobAsyncExecutor = Executors.newCachedThreadPool();
         findJobs().forEach(job -> jobManager.removeJobById(job.getId()));
-        jobManager.getQueue(NAME).removeAll();
+
+        // TODO maybe someday something like below - does not work due to outdated queues etc.
+        // jobManager.getQueue(NAME).removeAll();
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 10bab10a..e4590226 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -285,10 +285,14 @@ private boolean checkScript(Script script, ResourceResolver resourceResolver) {
                 return false;
             }
 
-            int queueCurrentSize = executionQueue.getCurrentSize();
-            int queueMaxSize = executionQueue.getMaxSize();
+            long queueCurrentSize = executionQueue.getCurrentSize();
+            long queueMaxSize = executionQueue.getMaxSize();
             if (queueCurrentSize >= queueMaxSize) {
-                LOG.info("Script '{}' not queued because queue is full ({}/{})!", script.getPath(), queueCurrentSize, queueMaxSize);
+                LOG.info(
+                        "Script '{}' not queued because queue is full ({}/{})!",
+                        script.getPath(),
+                        queueCurrentSize,
+                        queueMaxSize);
                 return false;
             }
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index 65d41052..19a11f49 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -35,6 +35,8 @@ public class QueueCodeServlet extends SlingAllMethodsServlet {
 
     public static final String EXECUTION_ID_PARAM = "executionId";
 
+    public static final String EXECUTION_ID_ALL = "all";
+
     private static final Logger LOG = LoggerFactory.getLogger(QueueCodeServlet.class);
 
     @Reference
@@ -129,26 +131,42 @@ protected void doDelete(SlingHttpServletRequest request, SlingHttpServletRespons
             return;
         }
 
-        try {
-            List<Execution> executions = executionQueue.readAll(executionIds).collect(Collectors.toList());
-            if (executions.isEmpty()) {
+        if (executionIds.size() == 1 && StringUtils.equalsIgnoreCase(executionIds.get(0), EXECUTION_ID_ALL)) {
+            try {
+                executionQueue.reset();
+                QueueOutput output = new QueueOutput(Collections.emptyList());
+                respondJson(response, ok("Code execution reset successfully", output));
+            } catch (Exception e) {
+                LOG.error("Code execution cannot be reset!", e);
                 respondJson(
                         response,
-                        notFound(String.format(
-                                "Code execution with ID '%s' not found!", StringUtils.join(executionIds, ","))));
+                        error(String.format("Code execution cannot be reset! %s", e.getMessage())
+                                .trim()));
                 return;
             }
+        } else {
+            try {
+                List<Execution> executions =
+                        executionQueue.readAll(executionIds).collect(Collectors.toList());
+                if (executions.isEmpty()) {
+                    respondJson(
+                            response,
+                            notFound(String.format(
+                                    "Code execution with ID '%s' not found!", StringUtils.join(executionIds, ","))));
+                    return;
+                }
 
-            executions.forEach(e -> executionQueue.stop(e.getId()));
+                executions.forEach(e -> executionQueue.stop(e.getId()));
 
-            QueueOutput output = new QueueOutput(executions);
-            respondJson(response, ok("Code execution stopped successfully", output));
-        } catch (Exception e) {
-            LOG.error("Code execution cannot be stopped!", e);
-            respondJson(
-                    response,
-                    error(String.format("Code execution cannot be stopped! %s", e.getMessage())
-                            .trim()));
+                QueueOutput output = new QueueOutput(executions);
+                respondJson(response, ok("Code execution stopped successfully", output));
+            } catch (Exception e) {
+                LOG.error("Code execution cannot be stopped!", e);
+                respondJson(
+                        response,
+                        error(String.format("Code execution cannot be stopped! %s", e.getMessage())
+                                .trim()));
+            }
         }
     }
 }
diff --git a/ui.frontend/src/components/ExecutionsResetButton.tsx b/ui.frontend/src/components/ExecutionsResetButton.tsx
new file mode 100644
index 00000000..121fff3b
--- /dev/null
+++ b/ui.frontend/src/components/ExecutionsResetButton.tsx
@@ -0,0 +1,70 @@
+import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, Text } from '@adobe/react-spectrum';
+import Cancel from '@spectrum-icons/workflow/Cancel';
+import Checkmark from '@spectrum-icons/workflow/Checkmark';
+import GearsDelete from '@spectrum-icons/workflow/GearsDelete';
+import React, { useState } from 'react';
+import { toastRequest } from '../utils/api';
+import { QueueOutput } from '../utils/api.types.ts';
+
+type ExecutionsResetButtonProps = {
+  onReset?: () => void;
+};
+
+const ExecutionsResetButton: React.FC<ExecutionsResetButtonProps> = ({ onReset }) => {
+  const [resetDialogOpen, setResetDialogOpen] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const handleConfirm = async () => {
+    setIsLoading(true);
+    try {
+      await toastRequest<QueueOutput>({
+        method: 'DELETE',
+        url: `/apps/acm/api/queue-code.json?executionId=all`,
+        operation: 'Reset executions',
+      });
+      if (onReset) onReset();
+    } catch (error) {
+      console.error('Reset executions error:', error);
+    } finally {
+      setIsLoading(false);
+      setResetDialogOpen(false);
+    }
+  };
+
+  const renderResetDialog = () => (
+    <>
+      <Heading>
+        <Text>Confirmation</Text>
+      </Heading>
+      <Divider />
+      <Content>
+        <Text>
+          <p>Are you sure you want to reset executor? This will stop all pending executions and remove all jobs from the Sling queue.</p>
+          <p>This action cannot be undone.</p>
+        </Text>
+      </Content>
+      <ButtonGroup>
+        <Button variant="secondary" onPress={() => setResetDialogOpen(false)} isDisabled={isLoading}>
+          <Cancel />
+          <Text>Cancel</Text>
+        </Button>
+        <Button variant="negative" style="fill" onPress={handleConfirm} isPending={isLoading}>
+          <Checkmark />
+          <Text>Confirm</Text>
+        </Button>
+      </ButtonGroup>
+    </>
+  );
+
+  return (
+    <DialogTrigger isOpen={resetDialogOpen} onOpenChange={setResetDialogOpen}>
+      <Button variant="secondary" style="fill" onPress={() => setResetDialogOpen(true)}>
+        <GearsDelete />
+        <Text>Reset</Text>
+      </Button>
+      <Dialog>{renderResetDialog()}</Dialog>
+    </DialogTrigger>
+  );
+};
+
+export default ExecutionsResetButton;
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index 9234edc5..adefd0bf 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -1,18 +1,18 @@
 import { Button, ButtonGroup, Cell, Column, Content, ContextualHelp, Flex, Heading, IllustratedMessage, ProgressBar, Row, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import NotFound from '@spectrum-icons/illustrations/NotFound';
 import Magnify from '@spectrum-icons/workflow/Magnify';
+import Settings from '@spectrum-icons/workflow/Settings';
 import React from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app';
 import { useFormatter } from '../hooks/formatter';
 import { useScripts } from '../hooks/script';
-import {instancePrefix, InstanceType, isExecutionNegative, ScriptType} from '../utils/api.types';
+import { instancePrefix, InstanceType, isExecutionNegative, ScriptType } from '../utils/api.types';
 import DateExplained from './DateExplained';
 import ExecutionStatsBadge from './ExecutionStatsBadge';
 import ScriptExecutorStatusLight from './ScriptExecutorStatusLight';
 import ScriptsAutomaticHelpButton from './ScriptsAutomaticHelpButton';
 import UserInfo from './UserInfo';
-import Settings from "@spectrum-icons/workflow/Settings";
 
 const ScriptAutomaticList: React.FC = () => {
   const appState = useAppState();
@@ -43,9 +43,9 @@ const ScriptAutomaticList: React.FC = () => {
           <Flex flex="1" alignItems="center">
             <ButtonGroup>
               <Button
-                  variant="negative"
-                  isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
-                  onPress={() => window.open(`${instancePrefix}/system/console/configMgr/dev.vml.es.acm.core.script.AutomaticScriptScheduler`, '_blank')}
+                variant="negative"
+                isDisabled={appState.instanceSettings.type === InstanceType.CLOUD_CONTAINER}
+                onPress={() => window.open(`${instancePrefix}/system/console/configMgr/dev.vml.es.acm.core.script.AutomaticScriptScheduler`, '_blank')}
               >
                 <Settings />
                 <Text>Configure</Text>
@@ -60,13 +60,7 @@ const ScriptAutomaticList: React.FC = () => {
           </Flex>
         </Flex>
       </View>
-      <TableView
-        flex="1"
-        aria-label={`Script list (${ScriptType.AUTOMATIC.toLowerCase()})`}
-        selectionMode={'none'}
-        renderEmptyState={renderEmptyState}
-        onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}
-      >
+      <TableView flex="1" aria-label={`Script list (${ScriptType.AUTOMATIC.toLowerCase()})`} selectionMode={'none'} renderEmptyState={renderEmptyState} onAction={(key) => navigate(`/scripts/view/${encodeURIComponent(key)}`)}>
         <TableHeader>
           <Column width="4fr">Name</Column>
           <Column width="5fr">Last Execution</Column>
diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/ScriptExecutor.tsx
index 6fc75824..bdcdc98e 100644
--- a/ui.frontend/src/components/ScriptExecutor.tsx
+++ b/ui.frontend/src/components/ScriptExecutor.tsx
@@ -1,9 +1,9 @@
 import { Button, ButtonGroup, Cell, Column, Content, Dialog, DialogTrigger, Divider, Flex, Heading, IllustratedMessage, Item, Menu, MenuTrigger, Row, StatusLight, TableBody, TableHeader, TableView, Text, View } from '@adobe/react-spectrum';
 import { Key, Selection } from '@react-types/shared';
 import NoSearchResults from '@spectrum-icons/illustrations/NoSearchResults';
+import ApplicationDelivery from '@spectrum-icons/workflow/ApplicationDelivery';
 import Cancel from '@spectrum-icons/workflow/Cancel';
 import Checkmark from '@spectrum-icons/workflow/Checkmark';
-import ApplicationDelivery from '@spectrum-icons/workflow/ApplicationDelivery';
 import Clock from '@spectrum-icons/workflow/Clock';
 import Close from '@spectrum-icons/workflow/Close';
 import Code from '@spectrum-icons/workflow/Code';
@@ -14,11 +14,12 @@ import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useAppState } from '../hooks/app.ts';
 import { apiRequest } from '../utils/api.ts';
-import {ExecutionOutput, ExecutionSummary, instancePrefix, InstanceType} from '../utils/api.types.ts';
+import { ExecutionOutput, ExecutionSummary, instancePrefix, InstanceType } from '../utils/api.types.ts';
 import { intervalToTimeout } from '../utils/spectrum.ts';
 import DateExplained from './DateExplained';
 import ExecutableIdValue from './ExecutableIdValue';
 import ExecutionsAbortButton from './ExecutionsAbortButton';
+import ExecutionsResetButton from './ExecutionsResetButton';
 import ExecutionStatusBadge from './ExecutionStatusBadge';
 import UserInfo from './UserInfo';
 
@@ -99,6 +100,7 @@ const ScriptExecutor = () => {
                   </Item>
                 </Menu>
               </MenuTrigger>
+              <ExecutionsResetButton />
             </ButtonGroup>
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
diff --git a/ui.frontend/src/pages/HistoryPage.tsx b/ui.frontend/src/pages/HistoryPage.tsx
index 9e09d0d0..62d8084c 100644
--- a/ui.frontend/src/pages/HistoryPage.tsx
+++ b/ui.frontend/src/pages/HistoryPage.tsx
@@ -132,7 +132,9 @@ const HistoryPage = () => {
           </Picker>
           <Picker width="size-1000" label="Limit" selectedKey={String(limit)} onSelectionChange={(key) => setLimit(Number(key))}>
             {HistoryLimitOptions.map((value) => (
-                <Item key={String(value)}><Text>{value}</Text></Item>
+              <Item key={String(value)}>
+                <Text>{value}</Text>
+              </Item>
             ))}
           </Picker>
         </Flex>
diff --git a/ui.frontend/src/pages/ScriptsPage.tsx b/ui.frontend/src/pages/ScriptsPage.tsx
index 225a9ae6..ac01b2b2 100644
--- a/ui.frontend/src/pages/ScriptsPage.tsx
+++ b/ui.frontend/src/pages/ScriptsPage.tsx
@@ -1,8 +1,8 @@
 import { Flex, Item, TabList, TabPanels, Tabs, Text } from '@adobe/react-spectrum';
 import Beaker from '@spectrum-icons/workflow/Beaker';
-import Launch from '@spectrum-icons/workflow/Launch';
 import Extension from '@spectrum-icons/workflow/Extension';
 import Hand from '@spectrum-icons/workflow/Hand';
+import Launch from '@spectrum-icons/workflow/Launch';
 import ScriptAutomaticList from '../components/ScriptAutomaticList';
 import ScriptExtensionList from '../components/ScriptExtensionList';
 import ScriptManualList from '../components/ScriptManualList';

From 23d1125b4dc1562b5a387bf8379b513235297e42 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 14:18:24 +0200
Subject: [PATCH 53/83] Minor

---
 core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
index 23d0b8da..51d15776 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java
@@ -129,7 +129,7 @@ public TopicStatistics getStatistics() {
         return StreamUtils.asStream(jobManager.getTopicStatistics().iterator())
                 .filter(ts -> TOPIC.equals(ts.getTopic()))
                 .findFirst()
-                .orElseThrow(() -> new AcmException(String.format("Cannot find topic statistics for topic '%s'!", TOPIC)));
+                .orElseThrow(() -> new AcmException(String.format("Cannot find statistics for topic '%s'!", TOPIC)));
     }
 
     public long getMaxSize() {

From 3d8e1a383c0250807297eca46f9667af1518c1f7 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 17:06:37 +0200
Subject: [PATCH 54/83] Wait for healthy on schedule

---
 .../core/script/AutomaticScriptScheduler.java | 29 ++++++++++++++-----
 1 file changed, 21 insertions(+), 8 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index e4590226..c7d8a06c 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -60,15 +60,21 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
         String userImpersonationId();
 
         @AttributeDefinition(
-                name = "Health Check Interval",
+                name = "Health Check Retry Interval",
                 description = "Interval in milliseconds to retry health check if instance is not healthy")
-        long healthRetryInterval() default 1000 * 10; // 10 seconds
+        long healthCheckRetryInterval() default 1000 * 10; // 10 seconds
 
         @AttributeDefinition(
-                name = "Health Check Max Count - Boot",
+                name = "Health Check Retry Count On Boot",
                 description =
-                        "Maximum number of retries after booting instance or installing package with automatic scripts")
-        long healthRetryMaxCountBoot() default 90; // 90 times * 10 seconds = 15 minutes
+                        "Maximum number of retries when checking instance health on boot script execution")
+        long healthCheckRetryCountBoot() default 90; // * 10 seconds = 15 minutes
+
+        @AttributeDefinition(
+                name = "Health Check Retry Count On Cron Schedule",
+                description =
+                        "Maximum number of retries when checking instance health on cron schedule script execution")
+        long healthCheckRetryCountCron() default 5; //  * 10 seconds = 50 seconds
     }
 
     private Boolean instanceReady;
@@ -166,8 +172,8 @@ private Job bootJob() {
             unscheduleScripts();
             if (awaitInstanceHealthy(
                     "Automatic scripts queueing and scheduling",
-                    config.healthRetryMaxCountBoot(),
-                    config.healthRetryInterval())) {
+                    config.healthCheckRetryCountBoot(),
+                    config.healthCheckRetryInterval())) {
                 queueAndScheduleScripts();
             }
         };
@@ -259,6 +265,13 @@ private void scheduleCronScript(Script script, CronSchedule schedule) {
 
     private Job cronJob(String scriptPath) {
         return context -> {
+            if (!awaitInstanceHealthy(
+                    String.format("Cron schedule script '%s' queueing", scriptPath),
+                    config.healthCheckRetryCountCron(),
+                    config.healthCheckRetryInterval())) {
+                return;
+            }
+
             try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
                 ScriptRepository scriptRepository = new ScriptRepository(resourceResolver);
                 Script script = scriptRepository.read(scriptPath).orElse(null);
@@ -326,7 +339,7 @@ private boolean awaitInstanceHealthy(String operation, long retryMaxCount, long
         while (healthStatus == null || !healthStatus.isHealthy()) {
             if (retryCount >= retryMaxCount) {
                 LOG.error(
-                        "{} not reached healthy instance state after {} retries: {}",
+                        "{} aborted due to unhealthy instance state after {} retries: {}",
                         operation,
                         retryMaxCount,
                         healthStatus);

From 0621dc21e949999ceeb367436105bf0119f9ab68 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Thu, 14 Aug 2025 17:07:32 +0200
Subject: [PATCH 55/83] Wait for healthy on schedule

---
 .../dev/vml/es/acm/core/script/AutomaticScriptScheduler.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index c7d8a06c..ebd8ecb0 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -74,7 +74,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
                 name = "Health Check Retry Count On Cron Schedule",
                 description =
                         "Maximum number of retries when checking instance health on cron schedule script execution")
-        long healthCheckRetryCountCron() default 5; //  * 10 seconds = 50 seconds
+        long healthCheckRetryCountCron() default 6; // * 10 seconds = 1 minute
     }
 
     private Boolean instanceReady;

From ceb563c8c97513a32ced3a6ffe065eddf9c32f0d Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Sat, 16 Aug 2025 14:20:15 +0200
Subject: [PATCH 56/83] Pending compile

---
 .../vml/es/acm/core/script/AutomaticScriptScheduler.java  | 2 +-
 ui.frontend/src/components/ScriptExecutor.tsx             | 2 +-
 ui.frontend/src/hooks/code.ts                             | 8 +++++++-
 ui.frontend/src/pages/ConsolePage.tsx                     | 4 ++--
 ui.frontend/src/pages/HistoryPage.tsx                     | 5 +++--
 5 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index ebd8ecb0..551eb313 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -74,7 +74,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
                 name = "Health Check Retry Count On Cron Schedule",
                 description =
                         "Maximum number of retries when checking instance health on cron schedule script execution")
-        long healthCheckRetryCountCron() default 6; // * 10 seconds = 1 minute
+        long healthCheckRetryCountCron() default 3; // * 10 seconds = 30 seconds
     }
 
     private Boolean instanceReady;
diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/ScriptExecutor.tsx
index bdcdc98e..138825ae 100644
--- a/ui.frontend/src/components/ScriptExecutor.tsx
+++ b/ui.frontend/src/components/ScriptExecutor.tsx
@@ -96,7 +96,7 @@ const ScriptExecutor = () => {
                   </Item>
                   <Item key="org.apache.sling.event.jobs.QueueConfiguration~acmexecutionqueue">
                     <ApplicationDelivery />
-                    <Text>Execution Queue &mdash; Sling</Text>
+                    <Text>Sling Queue</Text>
                   </Item>
                 </Menu>
               </MenuTrigger>
diff --git a/ui.frontend/src/hooks/code.ts b/ui.frontend/src/hooks/code.ts
index be9776ca..884a32e0 100644
--- a/ui.frontend/src/hooks/code.ts
+++ b/ui.frontend/src/hooks/code.ts
@@ -13,10 +13,15 @@ export type SyntaxError = {
 
 export const useCompilation = (code: string | undefined, onCodeChange: (code: string) => void) => {
   const [compiling, setCompiling] = useState<boolean>(false);
+  const [pendingCompile, setPendingCompile] = useState<boolean>(false);
   const [syntaxError, setSyntaxError] = useState<SyntaxError | undefined>(undefined);
   const [compileError, setCompileError] = useState<string | undefined>(undefined);
   const [execution, setExecution] = useState<Execution | null>(null);
 
+  useEffect(() => {
+    setPendingCompile(true);
+  }, [code]);
+
   const compileCode = useCallback(async () => {
     setCompiling(true);
     try {
@@ -55,6 +60,7 @@ export const useCompilation = (code: string | undefined, onCodeChange: (code: st
       console.warn('Code parsing error!');
     } finally {
       setCompiling(false);
+      setPendingCompile(false);
     }
   }, [code]);
 
@@ -70,5 +76,5 @@ export const useCompilation = (code: string | undefined, onCodeChange: (code: st
     };
   }, [cancelCompilation, code]);
 
-  return [compiling, syntaxError, compileError, execution] as const;
+  return [compiling, pendingCompile, syntaxError, compileError, execution] as const;
 };
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index baf8cddd..a98227e9 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -23,7 +23,7 @@ const ConsolePage = () => {
   const appState = useAppState();
   const [selectedTab, setSelectedTab] = useState<'code' | 'output'>('code');
   const [code, setCode] = useState<string | undefined>(() => localStorage.getItem(StorageKeys.EDITOR_CODE) || undefined);
-  const [compiling, syntaxError, compileError, parseExecution] = useCompilation(code, (newCode) => localStorage.setItem(StorageKeys.EDITOR_CODE, newCode));
+  const [compiling, pendingCompile, syntaxError, compileError, parseExecution] = useCompilation(code, (newCode) => localStorage.setItem(StorageKeys.EDITOR_CODE, newCode));
   const [queuedExecution, setQueuedExecution] = useState<Execution | null>(null);
 
   const { execution, setExecution, executing, setExecuting } = useExecutionPolling(queuedExecution?.id, appState.spaSettings.executionPollInterval);
@@ -121,7 +121,7 @@ const ConsolePage = () => {
               <Flex direction="row" justifyContent="space-between" alignItems="center">
                 <Flex flex="1" alignItems="center">
                   <ButtonGroup>
-                    <CodeExecuteButton code={code || ''} onDescribeFailed={onDescribeFailed} onExecute={onExecute} isPending={executing || compiling} isDisabled={!!syntaxError || !!compileError} />
+                    <CodeExecuteButton code={code || ''} onDescribeFailed={onDescribeFailed} onExecute={onExecute} isPending={executing || compiling} isDisabled={pendingCompile || !!syntaxError || !!compileError} />
                   </ButtonGroup>
                 </Flex>
                 <Flex flex="1" justifyContent="center" alignItems="center">
diff --git a/ui.frontend/src/pages/HistoryPage.tsx b/ui.frontend/src/pages/HistoryPage.tsx
index 62d8084c..85bdb7d7 100644
--- a/ui.frontend/src/pages/HistoryPage.tsx
+++ b/ui.frontend/src/pages/HistoryPage.tsx
@@ -24,7 +24,8 @@ import { Dates } from '../utils/dates';
 import { Urls } from '../utils/url';
 
 const HistoryFilterDelay = 1500;
-const HistoryLimitOptions = [10, 20, 30, 50, 100, 200, 500];
+const HistoryLimitOptions = [10, 20, 30, 50, 100, 200, 400, 800];
+const HistoryLimitDefault = HistoryLimitOptions[2];
 
 const HistoryPage = () => {
   const navigate = useNavigate();
@@ -37,7 +38,7 @@ const HistoryPage = () => {
   const [executableId, setExecutableId] = useState<string>(searchState.get(ExecutionQueryParams.EXECUTABLE_ID) || '');
   const [userId, setUserId] = useState<string>(searchState.get(ExecutionQueryParams.USER_ID) || '');
   const [status, setStatus] = useState<string | null>(searchState.get(ExecutionQueryParams.STATUS) || 'all');
-  const [limit, setLimit] = useState<number>(Number(searchState.get(ExecutionQueryParams.LIMIT)) || HistoryLimitOptions[0]);
+  const [limit, setLimit] = useState<number>(Number(searchState.get(ExecutionQueryParams.LIMIT)) || HistoryLimitDefault);
 
   const [durationMinInitial, durationMaxInitial] = (() => {
     const searchParam = searchState.get(ExecutionQueryParams.DURATION);

From 0b7e3a58edaf62cdefb6ecc1eb0e3168bb395f6f Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 09:38:02 +0200
Subject: [PATCH 57/83] Events for distributed actions

---
 .../es/acm/core/code/ExecutionHistory.java    | 14 +++
 .../java/dev/vml/es/acm/core/event/Event.java | 10 +++
 .../es/acm/core/event/EventDispatcher.java    | 58 ++++++++++++
 .../vml/es/acm/core/event/EventListener.java  |  6 ++
 .../vml/es/acm/core/event/EventManager.java   |  6 ++
 .../dev/vml/es/acm/core/event/EventType.java  | 15 ++++
 .../vml/es/acm/core/event/ResourceEvent.java  | 59 ++++++++++++
 .../acm/core/event/ResourceEventManager.java  | 89 +++++++++++++++++++
 .../vml/es/acm/core/servlet/EventServlet.java | 55 ++++++++++++
 .../es/acm/core/servlet/QueueCodeServlet.java | 51 ++++-------
 .../jcr_root/apps/acm/api/event/.content.xml  |  5 ++
 .../src/components/ExecutionsResetButton.tsx  |  4 +-
 12 files changed, 338 insertions(+), 34 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/Event.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/EventListener.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/EventType.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
 create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/api/event/.content.xml

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
index e90b3ae5..f037a151 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionHistory.java
@@ -120,4 +120,18 @@ private Stream<ExecutionSummary> toExecutionSummaries(Stream<Resource> entries)
     private Stream<Resource> executeSql(String sql) {
         return StreamUtils.asStream(resourceResolver.findResources(sql, Query.JCR_SQL2));
     }
+
+    public boolean clear() {
+        try {
+            Resource root = resourceResolver.getResource(ROOT);
+            if (root != null) {
+                resourceResolver.delete(root);
+                resourceResolver.commit();
+                return true;
+            }
+            return false;
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot clear execution history at root '%s'!", ROOT), e);
+        }
+    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/Event.java b/core/src/main/java/dev/vml/es/acm/core/event/Event.java
new file mode 100644
index 00000000..dbca862e
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/Event.java
@@ -0,0 +1,10 @@
+package dev.vml.es.acm.core.event;
+
+import java.util.Calendar;
+
+public interface Event {
+
+    String getName();
+
+    Calendar getTriggeredAt();
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
new file mode 100644
index 00000000..b10520a6
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
@@ -0,0 +1,58 @@
+package dev.vml.es.acm.core.event;
+
+import dev.vml.es.acm.core.code.ExecutionHistory;
+import dev.vml.es.acm.core.code.ExecutionQueue;
+import dev.vml.es.acm.core.util.ResourceUtils;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component(
+        service = {EventDispatcher.class},
+        immediate = true
+)
+public class EventDispatcher implements EventListener {
+
+    private final Logger LOG = LoggerFactory.getLogger(EventDispatcher.class);
+
+    @Reference
+    private EventManager eventManager;
+
+    @Reference
+    private ExecutionQueue executionQueue;
+
+    @Reference
+    private ResourceResolverFactory resourceResolverFactory;
+
+    public void dispatch(EventType eventType) {
+        eventManager.triggerEvent(eventType.name());
+    }
+
+    @Override
+    public void onEvent(Event event) {
+        switch (EventType.valueOf(event.getName())) {
+            case EXECUTION_QUEUE_RESET:
+                executionQueue.reset();
+                break;
+            case HISTORY_CLEAR:
+                doHistoryClear();
+                break;
+            default:
+                // not handled
+                break;
+        }
+    }
+
+    private void doHistoryClear() {
+        try (ResourceResolver resolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            ExecutionHistory executionHistory = new ExecutionHistory(resolver);
+            executionHistory.clear();
+        } catch (LoginException e) {
+            LOG.error("Cannot access repository while clearing history!", e);
+        }
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventListener.java b/core/src/main/java/dev/vml/es/acm/core/event/EventListener.java
new file mode 100644
index 00000000..d09e91e0
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventListener.java
@@ -0,0 +1,6 @@
+package dev.vml.es.acm.core.event;
+
+public interface EventListener {
+
+    void onEvent(Event event);
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java b/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
new file mode 100644
index 00000000..52b105d8
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
@@ -0,0 +1,6 @@
+package dev.vml.es.acm.core.event;
+
+public interface EventManager {
+
+    void triggerEvent(String name);
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventType.java b/core/src/main/java/dev/vml/es/acm/core/event/EventType.java
new file mode 100644
index 00000000..32c44b47
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventType.java
@@ -0,0 +1,15 @@
+package dev.vml.es.acm.core.event;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+public enum EventType {
+    EXECUTION_QUEUE_RESET,
+    HISTORY_CLEAR;
+
+    public static Optional<EventType> of(String name) {
+        return Arrays.stream(values())
+                .filter(s -> s.name().equalsIgnoreCase(name))
+                .findFirst();
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
new file mode 100644
index 00000000..3eb38313
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -0,0 +1,59 @@
+package dev.vml.es.acm.core.event;
+
+import dev.vml.es.acm.core.AcmConstants;
+import dev.vml.es.acm.core.repo.RepoResource;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import org.apache.jackrabbit.JcrConstants;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Map;
+
+public class ResourceEvent implements Event {
+
+    public static final String ROOT = AcmConstants.VAR_ROOT + "/event";
+
+    public static final String TRIGGERED_AT_PROP = "triggeredAt";
+
+    private final String name;
+
+    private final Calendar triggeredAt;
+
+    public ResourceEvent(Resource resource) {
+        this.name = StringUtils.substringAfter(resource.getPath(), ROOT + "/");
+        this.triggeredAt = resource.getValueMap().get(TRIGGERED_AT_PROP, Calendar.class);
+    }
+
+    public static ResourceEvent create(String name, ResourceResolver resolver) {
+        RepoResource result = RepoResource.of(resolver, String.format("%s/%s", ROOT, name));
+        Map<String, Object> properties = new HashMap<>();
+        properties.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
+        properties.put(TRIGGERED_AT_PROP, Calendar.getInstance());
+        result.save(properties);
+        return new ResourceEvent(result.require());
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Calendar getTriggeredAt() {
+        return triggeredAt;
+    }
+
+    public String getPath() {
+        return ROOT + "/" + name;
+    }
+
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
+                .append("name", name)
+                .append("triggeredAt", triggeredAt)
+                .toString();
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
new file mode 100644
index 00000000..e6bb8d88
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
@@ -0,0 +1,89 @@
+package dev.vml.es.acm.core.event;
+
+import com.day.cq.replication.ReplicationActionType;
+import com.day.cq.replication.ReplicationException;
+import com.day.cq.replication.Replicator;
+import dev.vml.es.acm.core.AcmException;
+import dev.vml.es.acm.core.util.ResourceUtils;
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.jetbrains.annotations.NotNull;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.jcr.Session;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Collectors;
+
+@Component(
+        service = {EventManager.class, ResourceChangeListener.class},
+        immediate = true,
+        property = {
+                ResourceChangeListener.PATHS + "=glob:" + ResourceEvent.ROOT + "/**/*",
+                ResourceChangeListener.CHANGES + "=ADDED",
+                ResourceChangeListener.CHANGES + "=CHANGED",
+        })
+public class ResourceEventManager implements EventManager, ResourceChangeListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(ResourceEventManager.class);
+
+    @Reference
+    private Replicator replicator;
+
+    @Reference
+    private ResourceResolverFactory resourceResolverFactory;
+
+    @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, service = EventListener.class)
+    private final Collection<EventListener> listeners = new CopyOnWriteArrayList<>();
+
+    @Override
+    public void triggerEvent(String name) {
+        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            LOG.debug("Triggering event: {}", name);
+            ResourceEvent event = ResourceEvent.create(name, resourceResolver);
+            replicator.replicate(resourceResolver.adaptTo(Session.class), ReplicationActionType.ACTIVATE, event.getPath());
+            LOG.debug("Triggered event: {}", event);
+        } catch (LoginException e) {
+            throw new AcmException(String.format("Cannot access repository while triggering event '%s'!", name), e);
+        } catch (ReplicationException e) {
+            throw new AcmException(String.format("Cannot replicate event '%s'!", name), e);
+        }
+    }
+
+    @Override
+    public void onChange(@NotNull List<ResourceChange> changes) {
+        if (CollectionUtils.isEmpty(changes) || listeners.isEmpty()) {
+            return;
+        }
+        try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
+            Collection<Event> events = changes.stream()
+                    .map(c -> resourceResolver.getResource(c.getPath()))
+                    .filter(Objects::nonNull)
+                    .map(ResourceEvent::new)
+                    .collect(Collectors.toList());
+
+            events.forEach(event -> {
+                LOG.debug("Dispatching event to listeners ({}): {}", listeners.size(), event);
+                listeners.forEach(listener -> {
+                    try {
+                        listener.onEvent(event);
+                    } catch (Exception e) {
+                        LOG.error("Event listener '{}' cannot handle event properly!", listener.getClass().getName(), e);
+                    }
+                });
+                LOG.debug("Dispatched event to listeners ({}): {}", listeners.size(), event);
+            });
+        } catch (LoginException e) {
+            throw new AcmException("Cannot access repository while processing events!", e);
+        }
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
new file mode 100644
index 00000000..d424fad1
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
@@ -0,0 +1,55 @@
+package dev.vml.es.acm.core.servlet;
+
+import dev.vml.es.acm.core.event.EventDispatcher;
+import dev.vml.es.acm.core.event.EventType;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.SlingHttpServletResponse;
+import org.apache.sling.api.servlets.ServletResolverConstants;
+import org.apache.sling.api.servlets.SlingAllMethodsServlet;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.Servlet;
+import java.io.IOException;
+
+import static dev.vml.es.acm.core.util.ServletResult.*;
+import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
+
+@Component(
+        service = {Servlet.class},
+        property = {
+            ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + EventServlet.RT,
+            ServletResolverConstants.SLING_SERVLET_METHODS + "=POST",
+            ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json",
+        })
+public class EventServlet extends SlingAllMethodsServlet {
+
+    public static final String RT = "acm/api/event";
+
+    public static final String NAME_PARAM = "name";
+
+    private static final Logger LOG = LoggerFactory.getLogger(EventServlet.class);
+
+    @Reference
+    private EventDispatcher dispatcher;
+
+    @Override
+    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
+        String name = request.getParameter(NAME_PARAM);
+        EventType event = EventType.of(name).orElse(null);
+        if (event == null) {
+            respondJson(response, badRequest(String.format("Event '%s' is not supported!", name)));
+            return;
+        }
+
+        try {
+            dispatcher.dispatch(event);
+            respondJson(response, ok(String.format("Event '%s' dispatched!", name)));
+        } catch (Exception e) {
+            LOG.error("Event '{}' cannot be dispatched!", name, e);
+            respondJson(response, badRequest(String.format("Event '%s' cannot be dispatched! %s", name, e.getMessage()).trim()));
+        }
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index 19a11f49..18931bc4 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -4,6 +4,7 @@
 import static dev.vml.es.acm.core.util.ServletUtils.*;
 
 import dev.vml.es.acm.core.code.*;
+import dev.vml.es.acm.core.event.EventDispatcher;
 import dev.vml.es.acm.core.util.JsonUtils;
 import java.io.IOException;
 import java.util.Collections;
@@ -35,13 +36,14 @@ public class QueueCodeServlet extends SlingAllMethodsServlet {
 
     public static final String EXECUTION_ID_PARAM = "executionId";
 
-    public static final String EXECUTION_ID_ALL = "all";
-
     private static final Logger LOG = LoggerFactory.getLogger(QueueCodeServlet.class);
 
     @Reference
     private ExecutionQueue executionQueue;
 
+    @Reference
+    private EventDispatcher eventDispatcher;
+
     @Reference
     private Executor executor;
 
@@ -131,42 +133,27 @@ protected void doDelete(SlingHttpServletRequest request, SlingHttpServletRespons
             return;
         }
 
-        if (executionIds.size() == 1 && StringUtils.equalsIgnoreCase(executionIds.get(0), EXECUTION_ID_ALL)) {
-            try {
-                executionQueue.reset();
-                QueueOutput output = new QueueOutput(Collections.emptyList());
-                respondJson(response, ok("Code execution reset successfully", output));
-            } catch (Exception e) {
-                LOG.error("Code execution cannot be reset!", e);
+        try {
+            List<Execution> executions =
+                    executionQueue.readAll(executionIds).collect(Collectors.toList());
+            if (executions.isEmpty()) {
                 respondJson(
                         response,
-                        error(String.format("Code execution cannot be reset! %s", e.getMessage())
-                                .trim()));
+                        notFound(String.format(
+                                "Code execution with ID '%s' not found!", StringUtils.join(executionIds, ","))));
                 return;
             }
-        } else {
-            try {
-                List<Execution> executions =
-                        executionQueue.readAll(executionIds).collect(Collectors.toList());
-                if (executions.isEmpty()) {
-                    respondJson(
-                            response,
-                            notFound(String.format(
-                                    "Code execution with ID '%s' not found!", StringUtils.join(executionIds, ","))));
-                    return;
-                }
 
-                executions.forEach(e -> executionQueue.stop(e.getId()));
+            executions.forEach(e -> executionQueue.stop(e.getId()));
 
-                QueueOutput output = new QueueOutput(executions);
-                respondJson(response, ok("Code execution stopped successfully", output));
-            } catch (Exception e) {
-                LOG.error("Code execution cannot be stopped!", e);
-                respondJson(
-                        response,
-                        error(String.format("Code execution cannot be stopped! %s", e.getMessage())
-                                .trim()));
-            }
+            QueueOutput output = new QueueOutput(executions);
+            respondJson(response, ok("Code execution stopped successfully", output));
+        } catch (Exception e) {
+            LOG.error("Code execution cannot be stopped!", e);
+            respondJson(
+                    response,
+                    error(String.format("Code execution cannot be stopped! %s", e.getMessage())
+                            .trim()));
         }
     }
 }
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/api/event/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/api/event/.content.xml
new file mode 100644
index 00000000..93bb3d8f
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/api/event/.content.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" xmlns:rep="internal"
+  jcr:mixinTypes="[rep:AccessControllable]"
+  jcr:primaryType="nt:unstructured"
+  sling:resourceType="acm/api/event"/>
diff --git a/ui.frontend/src/components/ExecutionsResetButton.tsx b/ui.frontend/src/components/ExecutionsResetButton.tsx
index 121fff3b..a22aff4f 100644
--- a/ui.frontend/src/components/ExecutionsResetButton.tsx
+++ b/ui.frontend/src/components/ExecutionsResetButton.tsx
@@ -18,8 +18,8 @@ const ExecutionsResetButton: React.FC<ExecutionsResetButtonProps> = ({ onReset }
     setIsLoading(true);
     try {
       await toastRequest<QueueOutput>({
-        method: 'DELETE',
-        url: `/apps/acm/api/queue-code.json?executionId=all`,
+        method: 'POST',
+        url: `/apps/acm/api/event.json?name=execution_queue_reset`,
         operation: 'Reset executions',
       });
       if (onReset) onReset();

From 6518ac93973617f29e7aaa2d966b6ef2c1f3fc7f Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 09:40:25 +0200
Subject: [PATCH 58/83] Events

---
 .../java/dev/vml/es/acm/core/event/EventDispatcher.java     | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
index b10520a6..2594775a 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
@@ -34,7 +34,11 @@ public void dispatch(EventType eventType) {
 
     @Override
     public void onEvent(Event event) {
-        switch (EventType.valueOf(event.getName())) {
+        EventType eventType = EventType.of(event.getName()).orElse(null);
+        if (eventType == null) {
+            return;
+        }
+        switch (eventType) {
             case EXECUTION_QUEUE_RESET:
                 executionQueue.reset();
                 break;

From 747e28e23ef12bf3e17cfdaf90fff4bc848024cd Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 09:53:26 +0200
Subject: [PATCH 59/83] Events

---
 core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
index 3eb38313..bedae907 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -33,6 +33,7 @@ public static ResourceEvent create(String name, ResourceResolver resolver) {
         Map<String, Object> properties = new HashMap<>();
         properties.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
         properties.put(TRIGGERED_AT_PROP, Calendar.getInstance());
+        result.parent().ensureFolder();
         result.save(properties);
         return new ResourceEvent(result.require());
     }

From 4d3646522821b9b3912d41cfed9fd637ed4411dd Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 09:55:09 +0200
Subject: [PATCH 60/83] Events

---
 .../es/acm/core/event/EventDispatcher.java    |  3 +-
 .../dev/vml/es/acm/core/event/EventType.java  |  2 +-
 .../vml/es/acm/core/event/ResourceEvent.java  |  7 ++---
 .../acm/core/event/ResourceEventManager.java  | 28 +++++++++++--------
 .../core/script/AutomaticScriptScheduler.java |  3 +-
 .../vml/es/acm/core/servlet/EventServlet.java | 16 ++++++-----
 .../es/acm/core/servlet/QueueCodeServlet.java |  3 +-
 7 files changed, 33 insertions(+), 29 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
index 2594775a..0f4ce58b 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
@@ -13,8 +13,7 @@
 
 @Component(
         service = {EventDispatcher.class},
-        immediate = true
-)
+        immediate = true)
 public class EventDispatcher implements EventListener {
 
     private final Logger LOG = LoggerFactory.getLogger(EventDispatcher.class);
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventType.java b/core/src/main/java/dev/vml/es/acm/core/event/EventType.java
index 32c44b47..4bb94154 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventType.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventType.java
@@ -12,4 +12,4 @@ public static Optional<EventType> of(String name) {
                 .filter(s -> s.name().equalsIgnoreCase(name))
                 .findFirst();
     }
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
index bedae907..70e59448 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -2,6 +2,9 @@
 
 import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.repo.RepoResource;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
@@ -9,10 +12,6 @@
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
 
-import java.util.Calendar;
-import java.util.HashMap;
-import java.util.Map;
-
 public class ResourceEvent implements Event {
 
     public static final String ROOT = AcmConstants.VAR_ROOT + "/event";
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
index e6bb8d88..785abf18 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
@@ -5,6 +5,10 @@
 import com.day.cq.replication.Replicator;
 import dev.vml.es.acm.core.AcmException;
 import dev.vml.es.acm.core.util.ResourceUtils;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Collectors;
+import javax.jcr.Session;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -19,18 +23,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.jcr.Session;
-import java.util.*;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.stream.Collectors;
-
 @Component(
         service = {EventManager.class, ResourceChangeListener.class},
         immediate = true,
         property = {
-                ResourceChangeListener.PATHS + "=glob:" + ResourceEvent.ROOT + "/**/*",
-                ResourceChangeListener.CHANGES + "=ADDED",
-                ResourceChangeListener.CHANGES + "=CHANGED",
+            ResourceChangeListener.PATHS + "=glob:" + ResourceEvent.ROOT + "/**/*",
+            ResourceChangeListener.CHANGES + "=ADDED",
+            ResourceChangeListener.CHANGES + "=CHANGED",
         })
 public class ResourceEventManager implements EventManager, ResourceChangeListener {
 
@@ -42,7 +41,10 @@ public class ResourceEventManager implements EventManager, ResourceChangeListene
     @Reference
     private ResourceResolverFactory resourceResolverFactory;
 
-    @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, service = EventListener.class)
+    @Reference(
+            cardinality = ReferenceCardinality.MULTIPLE,
+            policy = ReferencePolicy.DYNAMIC,
+            service = EventListener.class)
     private final Collection<EventListener> listeners = new CopyOnWriteArrayList<>();
 
     @Override
@@ -50,7 +52,8 @@ public void triggerEvent(String name) {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             LOG.debug("Triggering event: {}", name);
             ResourceEvent event = ResourceEvent.create(name, resourceResolver);
-            replicator.replicate(resourceResolver.adaptTo(Session.class), ReplicationActionType.ACTIVATE, event.getPath());
+            replicator.replicate(
+                    resourceResolver.adaptTo(Session.class), ReplicationActionType.ACTIVATE, event.getPath());
             LOG.debug("Triggered event: {}", event);
         } catch (LoginException e) {
             throw new AcmException(String.format("Cannot access repository while triggering event '%s'!", name), e);
@@ -77,7 +80,10 @@ public void onChange(@NotNull List<ResourceChange> changes) {
                     try {
                         listener.onEvent(event);
                     } catch (Exception e) {
-                        LOG.error("Event listener '{}' cannot handle event properly!", listener.getClass().getName(), e);
+                        LOG.error(
+                                "Event listener '{}' cannot handle event properly!",
+                                listener.getClass().getName(),
+                                e);
                     }
                 });
                 LOG.debug("Dispatched event to listeners ({}): {}", listeners.size(), event);
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
index 551eb313..c1b1a1de 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/AutomaticScriptScheduler.java
@@ -66,8 +66,7 @@ public class AutomaticScriptScheduler implements ResourceChangeListener {
 
         @AttributeDefinition(
                 name = "Health Check Retry Count On Boot",
-                description =
-                        "Maximum number of retries when checking instance health on boot script execution")
+                description = "Maximum number of retries when checking instance health on boot script execution")
         long healthCheckRetryCountBoot() default 90; // * 10 seconds = 15 minutes
 
         @AttributeDefinition(
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
index d424fad1..9fc69e2e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
@@ -1,7 +1,12 @@
 package dev.vml.es.acm.core.servlet;
 
+import static dev.vml.es.acm.core.util.ServletResult.*;
+import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
+
 import dev.vml.es.acm.core.event.EventDispatcher;
 import dev.vml.es.acm.core.event.EventType;
+import java.io.IOException;
+import javax.servlet.Servlet;
 import org.apache.sling.api.SlingHttpServletRequest;
 import org.apache.sling.api.SlingHttpServletResponse;
 import org.apache.sling.api.servlets.ServletResolverConstants;
@@ -11,12 +16,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.servlet.Servlet;
-import java.io.IOException;
-
-import static dev.vml.es.acm.core.util.ServletResult.*;
-import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
-
 @Component(
         service = {Servlet.class},
         property = {
@@ -49,7 +48,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
             respondJson(response, ok(String.format("Event '%s' dispatched!", name)));
         } catch (Exception e) {
             LOG.error("Event '{}' cannot be dispatched!", name, e);
-            respondJson(response, badRequest(String.format("Event '%s' cannot be dispatched! %s", name, e.getMessage()).trim()));
+            respondJson(
+                    response,
+                    badRequest(String.format("Event '%s' cannot be dispatched! %s", name, e.getMessage())
+                            .trim()));
         }
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index 18931bc4..db0b2a37 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -134,8 +134,7 @@ protected void doDelete(SlingHttpServletRequest request, SlingHttpServletRespons
         }
 
         try {
-            List<Execution> executions =
-                    executionQueue.readAll(executionIds).collect(Collectors.toList());
+            List<Execution> executions = executionQueue.readAll(executionIds).collect(Collectors.toList());
             if (executions.isEmpty()) {
                 respondJson(
                         response,

From 2e25eb25e1eebe66fa3b6261d4e7f74e298a2360 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 10:00:52 +0200
Subject: [PATCH 61/83] Events

---
 .../main/java/dev/vml/es/acm/core/event/EventDispatcher.java    | 2 +-
 core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
index 0f4ce58b..6be22514 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
@@ -12,7 +12,7 @@
 import org.slf4j.LoggerFactory;
 
 @Component(
-        service = {EventDispatcher.class},
+        service = {EventDispatcher.class, EventListener.class},
         immediate = true)
 public class EventDispatcher implements EventListener {
 
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
index 70e59448..fd321e6d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -32,7 +32,7 @@ public static ResourceEvent create(String name, ResourceResolver resolver) {
         Map<String, Object> properties = new HashMap<>();
         properties.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
         properties.put(TRIGGERED_AT_PROP, Calendar.getInstance());
-        result.parent().ensureFolder();
+        result.parent().ensureRegularFolder();
         result.save(properties);
         return new ResourceEvent(result.require());
     }

From 597c287574da1e4f11e8a70dfd6fcc077a971d06 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 10:17:50 +0200
Subject: [PATCH 62/83] Events

---
 ui.frontend/src/components/ExecutionsResetButton.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui.frontend/src/components/ExecutionsResetButton.tsx b/ui.frontend/src/components/ExecutionsResetButton.tsx
index a22aff4f..cca67f3d 100644
--- a/ui.frontend/src/components/ExecutionsResetButton.tsx
+++ b/ui.frontend/src/components/ExecutionsResetButton.tsx
@@ -39,7 +39,7 @@ const ExecutionsResetButton: React.FC<ExecutionsResetButtonProps> = ({ onReset }
       <Divider />
       <Content>
         <Text>
-          <p>Are you sure you want to reset executor? This will stop all pending executions and remove all jobs from the Sling queue.</p>
+          <p>Are you sure you want to reset the executor? This action will <strong>affect all instances</strong>, immediately stop any ongoing executions, and clear all jobs from the Sling queue.<br /></p>
           <p>This action cannot be undone.</p>
         </Text>
       </Content>

From 0220b14fe07c5807492fe82ecd20e104d1afced6 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Mon, 18 Aug 2025 10:35:23 +0200
Subject: [PATCH 63/83] Events

---
 .../{ScriptExecutor.tsx => CodeExecutor.tsx}  |  4 +--
 .../src/components/ExecutionHistory.tsx       | 28 +++++++++++++++++++
 ui.frontend/src/pages/MaintenancePage.tsx     | 19 +++++++++----
 3 files changed, 44 insertions(+), 7 deletions(-)
 rename ui.frontend/src/components/{ScriptExecutor.tsx => CodeExecutor.tsx} (99%)
 create mode 100644 ui.frontend/src/components/ExecutionHistory.tsx

diff --git a/ui.frontend/src/components/ScriptExecutor.tsx b/ui.frontend/src/components/CodeExecutor.tsx
similarity index 99%
rename from ui.frontend/src/components/ScriptExecutor.tsx
rename to ui.frontend/src/components/CodeExecutor.tsx
index 138825ae..1a1c0213 100644
--- a/ui.frontend/src/components/ScriptExecutor.tsx
+++ b/ui.frontend/src/components/CodeExecutor.tsx
@@ -23,7 +23,7 @@ import ExecutionsResetButton from './ExecutionsResetButton';
 import ExecutionStatusBadge from './ExecutionStatusBadge';
 import UserInfo from './UserInfo';
 
-const ScriptExecutor = () => {
+const CodeExecutor = () => {
   const appState = useAppState();
   const navigate = useNavigate();
 
@@ -181,4 +181,4 @@ const ScriptExecutor = () => {
   );
 };
 
-export default ScriptExecutor;
+export default CodeExecutor;
diff --git a/ui.frontend/src/components/ExecutionHistory.tsx b/ui.frontend/src/components/ExecutionHistory.tsx
new file mode 100644
index 00000000..60b3c8cb
--- /dev/null
+++ b/ui.frontend/src/components/ExecutionHistory.tsx
@@ -0,0 +1,28 @@
+import { Button, Flex, Text, View } from '@adobe/react-spectrum';
+import DataRemove from "@spectrum-icons/workflow/DataRemove";
+import React from "react";
+
+const ExecutionHistory = () => {
+  return (
+    <Flex direction="column" flex="1" gap="size-200" marginY="size-100">
+      <View>
+        <Flex direction="row" justifyContent="space-between" alignItems="center">
+          <Flex flex="1" alignItems="center">
+            <Button variant="negative">
+              <DataRemove />
+              <Text>Clear</Text>
+            </Button>
+          </Flex>
+          <Flex flex="1" justifyContent="center" alignItems="center">
+            &nbsp;
+          </Flex>
+          <Flex flex="1" justifyContent="end" alignItems="center">
+            &nbsp;
+          </Flex>
+        </Flex>
+      </View>
+    </Flex>
+  );
+};
+
+export default ExecutionHistory;
diff --git a/ui.frontend/src/pages/MaintenancePage.tsx b/ui.frontend/src/pages/MaintenancePage.tsx
index 156091e9..f2dd442c 100644
--- a/ui.frontend/src/pages/MaintenancePage.tsx
+++ b/ui.frontend/src/pages/MaintenancePage.tsx
@@ -1,8 +1,10 @@
 import { Flex, Item, TabList, TabPanels, Tabs, Text } from '@adobe/react-spectrum';
 import Code from '@spectrum-icons/workflow/Code';
 import Heart from '@spectrum-icons/workflow/Heart';
+import History from '@spectrum-icons/workflow/History';
 import HealthChecker from '../components/HealthChecker';
-import ScriptExecutor from '../components/ScriptExecutor';
+import ExecutionHistory from '../components/ExecutionHistory';
+import CodeExecutor from '../components/CodeExecutor';
 import { useNavigationTab } from '../hooks/navigation';
 
 const MaintenancePage = () => {
@@ -12,22 +14,29 @@ const MaintenancePage = () => {
     <Flex direction="column" flex="1" gap="size-400">
       <Tabs flex="1" aria-label="Maintenance tabs" selectedKey={selectedTab} onSelectionChange={setSelectedTab}>
         <TabList>
-          <Item key="script-executor" aria-label="Script Executor">
+          <Item key="code-executor" aria-label="Code Executor">
             <Code />
-            <Text>Script Executor</Text>
+            <Text>Code Executor</Text>
           </Item>
           <Item key="health-checker" aria-label="Health Checker">
             <Heart />
             <Text>Health Checker</Text>
           </Item>
+          <Item key="execution-history" aria-label="Execution History">
+            <History/>
+            <Text>Execution History</Text>
+          </Item>
         </TabList>
         <TabPanels flex="1" UNSAFE_style={{ display: 'flex' }}>
-          <Item key="script-executor" aria-label="Script Executor">
-            <ScriptExecutor />
+          <Item key="code-executor" aria-label="Code Executor">
+            <CodeExecutor />
           </Item>
           <Item key="health-checker" aria-label="Health Checker">
             <HealthChecker />
           </Item>
+          <Item key="execution-history" aria-label="Execution History">
+            <ExecutionHistory />
+          </Item>
         </TabPanels>
       </Tabs>
     </Flex>

From 690a7fa6687d3e541b6d044a3c5cbfb319a28c01 Mon Sep 17 00:00:00 2001
From: jakub-berlinski-wttech
 <115461199+jakub-berlinski-wttech@users.noreply.github.com>
Date: Tue, 19 Aug 2025 08:50:01 +0200
Subject: [PATCH 64/83] showcase proper extension path in README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 03f6456e..320e3fcd 100644
--- a/README.md
+++ b/README.md
@@ -346,7 +346,7 @@ Complete output as well as argument values are also included to achieve full tra
 
 ### Extension scripts
 
-To add own code binding or hook into execution process, you can create your own extension Groovy scripts and place them at path like `/conf/acm/settings/extension/acme/main.groovy`.
+To add own code binding or hook into execution process, you can create your own extension Groovy scripts and place them at path like `/conf/acm/settings/script/extension/acme/main.groovy`.
 
 #### Example extension script
 

From 3c0367baf5fc15fc76c9e8bdd4dd9e30a3454a5d Mon Sep 17 00:00:00 2001
From: jakub-berlinski-wttech
 <115461199+jakub-berlinski-wttech@users.noreply.github.com>
Date: Tue, 19 Aug 2025 08:56:16 +0200
Subject: [PATCH 65/83] Update README.md - point to source code for Mocks OSGi
 configuration

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 320e3fcd..aed100a3 100644
--- a/README.md
+++ b/README.md
@@ -400,7 +400,7 @@ documentation: |
 ### Mocks
 
 ACM has incorporated the [AEM Stubs tool](https://github.com/wttech/aem-stubs), which allows you to create mock HTTP responses for testing purposes also via Groovy Scripts.
-This feature is disabled by default, but you can enable it in the [OSGi configuration](http://localhost:4502/system/console/configMgr/com.vml.es.aem.acm.core.mock.MockHttpFilter).
+This feature is disabled by default, but you can enable it in the [OSGi configuration](https://github.com/wttech/acm/blob/main/core/src/main/java/dev/vml/es/acm/core/mock/MockHttpFilter.java).
 
 <img src="docs/screenshot-scripts-mock-tab.png" width="720" alt="ACM Mocks - List">
 <img src="docs/screenshot-scripts-mock-code.png" width="720" alt="ACM Mocks - Code">

From 5f253d5bd408dd7838c4bef06b16aa087ac0aa5d Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Tue, 19 Aug 2025 14:32:10 +0200
Subject: [PATCH 66/83] Events

---
 .../vml/es/acm/core/servlet/EventServlet.java |  2 +-
 ui.frontend/src/components/CodeExecutor.tsx   |  2 +-
 .../src/components/ExecutionHistory.tsx       | 48 +++++++++--
 .../ExecutionHistoryClearButton.tsx           | 79 +++++++++++++++++++
 .../src/components/ExecutionsResetButton.tsx  | 10 ++-
 ui.frontend/src/pages/MaintenancePage.tsx     |  8 +-
 6 files changed, 133 insertions(+), 16 deletions(-)
 create mode 100644 ui.frontend/src/components/ExecutionHistoryClearButton.tsx

diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
index 9fc69e2e..59a4eb8e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
@@ -45,7 +45,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
 
         try {
             dispatcher.dispatch(event);
-            respondJson(response, ok(String.format("Event '%s' dispatched!", name)));
+            respondJson(response, ok(String.format("Event '%s' dispatched successfully!", name)));
         } catch (Exception e) {
             LOG.error("Event '{}' cannot be dispatched!", name, e);
             respondJson(
diff --git a/ui.frontend/src/components/CodeExecutor.tsx b/ui.frontend/src/components/CodeExecutor.tsx
index 1a1c0213..b3d679c5 100644
--- a/ui.frontend/src/components/CodeExecutor.tsx
+++ b/ui.frontend/src/components/CodeExecutor.tsx
@@ -114,7 +114,7 @@ const CodeExecutor = () => {
               </Button>
               {(close) => (
                 <Dialog>
-                  <Heading>Script Executor</Heading>
+                  <Heading>Execution History</Heading>
                   <Divider />
                   <Content>
                     <p>
diff --git a/ui.frontend/src/components/ExecutionHistory.tsx b/ui.frontend/src/components/ExecutionHistory.tsx
index 60b3c8cb..5d56fdc1 100644
--- a/ui.frontend/src/components/ExecutionHistory.tsx
+++ b/ui.frontend/src/components/ExecutionHistory.tsx
@@ -1,6 +1,10 @@
-import { Button, Flex, Text, View } from '@adobe/react-spectrum';
-import DataRemove from "@spectrum-icons/workflow/DataRemove";
-import React from "react";
+import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Flex, Heading, Text, View } from '@adobe/react-spectrum';
+import Alert from '@spectrum-icons/workflow/Alert';
+import ArchiveRemove from '@spectrum-icons/workflow/ArchiveRemove';
+import Close from '@spectrum-icons/workflow/Close';
+import Data from '@spectrum-icons/workflow/Data';
+import Help from '@spectrum-icons/workflow/Help';
+import ExecutionHistoryClearButton from './ExecutionHistoryClearButton';
 
 const ExecutionHistory = () => {
   return (
@@ -8,16 +12,44 @@ const ExecutionHistory = () => {
       <View>
         <Flex direction="row" justifyContent="space-between" alignItems="center">
           <Flex flex="1" alignItems="center">
-            <Button variant="negative">
-              <DataRemove />
-              <Text>Clear</Text>
-            </Button>
+            <ExecutionHistoryClearButton />
           </Flex>
           <Flex flex="1" justifyContent="center" alignItems="center">
             &nbsp;
           </Flex>
           <Flex flex="1" justifyContent="end" alignItems="center">
-            &nbsp;
+            <DialogTrigger>
+              <Button variant="secondary" style="fill">
+                <Help />
+                <Text>Help</Text>
+              </Button>
+              {(close) => (
+                <Dialog>
+                  <Heading>Execution History</Heading>
+                  <Divider />
+                  <Content>
+                    <p>
+                      <ArchiveRemove size="XS" /> All executions are automatically saved in the repository for future reference and auditing.
+                    </p>
+                    <p>
+                      <Data size="XS" /> Over time, the history may grow and consume repository space. Clearing the execution history can help free up space and maintain optimal repository performance.
+                    </p>
+                    <p>
+                      <ArchiveRemove size="XS" /> Use the "Clear" button to remove all past executions for all instances. This action cannot be undone.
+                    </p>
+                    <p>
+                      <Alert size="XS" /> <b>Do not clear execution history too often</b> &mdash; it may be unexpectedly useful for auditing or troubleshooting in the future.
+                    </p>
+                  </Content>
+                  <ButtonGroup>
+                    <Button variant="secondary" onPress={close}>
+                      <Close size="XS" />
+                      <Text>Close</Text>
+                    </Button>
+                  </ButtonGroup>
+                </Dialog>
+              )}
+            </DialogTrigger>
           </Flex>
         </Flex>
       </View>
diff --git a/ui.frontend/src/components/ExecutionHistoryClearButton.tsx b/ui.frontend/src/components/ExecutionHistoryClearButton.tsx
new file mode 100644
index 00000000..2647303e
--- /dev/null
+++ b/ui.frontend/src/components/ExecutionHistoryClearButton.tsx
@@ -0,0 +1,79 @@
+import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, Text } from '@adobe/react-spectrum';
+import Alert from '@spectrum-icons/workflow/Alert';
+import Cancel from '@spectrum-icons/workflow/Cancel';
+import Checkmark from '@spectrum-icons/workflow/Checkmark';
+import DataRemove from '@spectrum-icons/workflow/DataRemove';
+import Flashlight from '@spectrum-icons/workflow/Flashlight';
+import React, { useState } from 'react';
+import { toastRequest } from '../utils/api';
+import { QueueOutput } from '../utils/api.types.ts';
+
+type ExecutionHistoryClearButtonProps = {
+  onClear?: () => void;
+};
+
+const ExecutionHistoryClearButton: React.FC<ExecutionHistoryClearButtonProps> = ({ onClear }) => {
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const handleConfirm = async () => {
+    setIsLoading(true);
+    try {
+      await toastRequest<QueueOutput>({
+        method: 'POST',
+        url: `/apps/acm/api/event.json?name=history_clear`,
+        operation: 'Clear execution history',
+      });
+      if (onClear) onClear();
+    } catch (error) {
+      console.error('Clear execution history error:', error);
+    } finally {
+      setIsLoading(false);
+      setDialogOpen(false);
+    }
+  };
+
+  const renderDialog = () => (
+    <>
+      <Heading>
+        <Text>Confirmation</Text>
+      </Heading>
+      <Divider />
+      <Content>
+        <Text>
+          <p>
+            Are you sure you want to clear the execution history? This will remove all past executions <b>for all instances</b>.<br />
+          </p>
+          <p>
+            <Flashlight size="XS" /> Do not clear execution history <b>too often</b> &mdash; it may be unexpectedly useful for auditing or troubleshooting in the future.
+          </p>
+          <p>
+            <Alert size="XS" /> This action cannot be undone.
+          </p>
+        </Text>
+      </Content>
+      <ButtonGroup>
+        <Button variant="secondary" onPress={() => setDialogOpen(false)} isDisabled={isLoading}>
+          <Cancel />
+          <Text>Cancel</Text>
+        </Button>
+        <Button variant="negative" style="fill" onPress={handleConfirm} isPending={isLoading}>
+          <Checkmark />
+          <Text>Confirm</Text>
+        </Button>
+      </ButtonGroup>
+    </>
+  );
+
+  return (
+    <DialogTrigger isOpen={dialogOpen} onOpenChange={setDialogOpen}>
+      <Button variant="negative" onPress={() => setDialogOpen(true)}>
+        <DataRemove />
+        <Text>Clear</Text>
+      </Button>
+      <Dialog>{renderDialog()}</Dialog>
+    </DialogTrigger>
+  );
+};
+
+export default ExecutionHistoryClearButton;
diff --git a/ui.frontend/src/components/ExecutionsResetButton.tsx b/ui.frontend/src/components/ExecutionsResetButton.tsx
index cca67f3d..043fb6e5 100644
--- a/ui.frontend/src/components/ExecutionsResetButton.tsx
+++ b/ui.frontend/src/components/ExecutionsResetButton.tsx
@@ -1,4 +1,5 @@
 import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, Text } from '@adobe/react-spectrum';
+import Alert from '@spectrum-icons/workflow/Alert';
 import Cancel from '@spectrum-icons/workflow/Cancel';
 import Checkmark from '@spectrum-icons/workflow/Checkmark';
 import GearsDelete from '@spectrum-icons/workflow/GearsDelete';
@@ -39,8 +40,13 @@ const ExecutionsResetButton: React.FC<ExecutionsResetButtonProps> = ({ onReset }
       <Divider />
       <Content>
         <Text>
-          <p>Are you sure you want to reset the executor? This action will <strong>affect all instances</strong>, immediately stop any ongoing executions, and clear all jobs from the Sling queue.<br /></p>
-          <p>This action cannot be undone.</p>
+          <p>
+            Are you sure you want to reset the executor? This action will <strong>affect all instances</strong>, immediately stop any ongoing executions, and clear all jobs from the Sling queue.
+            <br />
+          </p>
+          <p>
+            <Alert size="XS" /> This action cannot be undone.
+          </p>
         </Text>
       </Content>
       <ButtonGroup>
diff --git a/ui.frontend/src/pages/MaintenancePage.tsx b/ui.frontend/src/pages/MaintenancePage.tsx
index f2dd442c..fa2850ab 100644
--- a/ui.frontend/src/pages/MaintenancePage.tsx
+++ b/ui.frontend/src/pages/MaintenancePage.tsx
@@ -2,13 +2,13 @@ import { Flex, Item, TabList, TabPanels, Tabs, Text } from '@adobe/react-spectru
 import Code from '@spectrum-icons/workflow/Code';
 import Heart from '@spectrum-icons/workflow/Heart';
 import History from '@spectrum-icons/workflow/History';
-import HealthChecker from '../components/HealthChecker';
-import ExecutionHistory from '../components/ExecutionHistory';
 import CodeExecutor from '../components/CodeExecutor';
+import ExecutionHistory from '../components/ExecutionHistory';
+import HealthChecker from '../components/HealthChecker';
 import { useNavigationTab } from '../hooks/navigation';
 
 const MaintenancePage = () => {
-  const [selectedTab, setSelectedTab] = useNavigationTab('script-executor');
+  const [selectedTab, setSelectedTab] = useNavigationTab('code-executor');
 
   return (
     <Flex direction="column" flex="1" gap="size-400">
@@ -23,7 +23,7 @@ const MaintenancePage = () => {
             <Text>Health Checker</Text>
           </Item>
           <Item key="execution-history" aria-label="Execution History">
-            <History/>
+            <History />
             <Text>Execution History</Text>
           </Item>
         </TabList>

From 796cad40a884fa5dab8e9118c7c8c8d2f4d5ce46 Mon Sep 17 00:00:00 2001
From: Krystian Panek
 <81212505+krystian-panek-vmltech@users.noreply.github.com>
Date: Tue, 19 Aug 2025 14:46:16 +0200
Subject: [PATCH 67/83] Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
 ui.frontend/src/components/CodeExecutor.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui.frontend/src/components/CodeExecutor.tsx b/ui.frontend/src/components/CodeExecutor.tsx
index b3d679c5..ae55b65b 100644
--- a/ui.frontend/src/components/CodeExecutor.tsx
+++ b/ui.frontend/src/components/CodeExecutor.tsx
@@ -114,7 +114,7 @@ const CodeExecutor = () => {
               </Button>
               {(close) => (
                 <Dialog>
-                  <Heading>Execution History</Heading>
+                  <Heading>Code Executor</Heading>
                   <Divider />
                   <Content>
                     <p>

From 673a6ed4a4fba8c56362976b0139334e69897e05 Mon Sep 17 00:00:00 2001
From: Krystian Panek
 <81212505+krystian-panek-vmltech@users.noreply.github.com>
Date: Tue, 19 Aug 2025 17:09:17 +0200
Subject: [PATCH 68/83] Update README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index aed100a3..9858be04 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,7 @@ Adjust file 'all/pom.xml':
 
 3. Consider refining the ACL settings
 
-   The default settings are defined in the [repo init OSGi config](https://github.com/wttech/acm/blob/main/ui.config/src/main/content/jcr_root/apps/acm-config/osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer~acm.config), which effectively restrict access to the tool and script execution to administrators only—a recommended practice for production environments.
+   The default settings are defined in the [repo init OSGi config](https://github.com/wttech/acm/blob/main/ui.config/src/main/content/jcr_root/apps/acm-config/osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer~acmcore.config), which effectively restrict access to the tool and script execution to administrators only—a recommended practice for production environments.
    If you require further customization, you can create your own repo init OSGi config to override or extend the default configuration.
 
 ## Compatibility

From 94ff7dfb98da44b3f43343dee18afc730afb3c94 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 08:02:10 +0200
Subject: [PATCH 69/83] Code save as script

---
 ui.frontend/src/components/CodeSaveButton.tsx | 97 +++++++++++++++++++
 ui.frontend/src/pages/ConsolePage.tsx         |  2 +
 ui.frontend/src/utils/api.types.ts            | 13 ++-
 3 files changed, 109 insertions(+), 3 deletions(-)
 create mode 100644 ui.frontend/src/components/CodeSaveButton.tsx

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
new file mode 100644
index 00000000..93a15ab7
--- /dev/null
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -0,0 +1,97 @@
+import { Button, ButtonGroup, Content, Dialog, DialogContainer, Divider, Form, Heading, Radio, RadioGroup, Text, TextField } from '@adobe/react-spectrum';
+import { ToastQueue } from '@react-spectrum/toast';
+import Checkmark from '@spectrum-icons/workflow/Checkmark';
+import Close from '@spectrum-icons/workflow/Close';
+import FileAdd from '@spectrum-icons/workflow/FileAdd';
+import Hand from '@spectrum-icons/workflow/Hand';
+import Launch from '@spectrum-icons/workflow/Launch';
+import React, { useState } from 'react';
+import { toastRequest } from '../utils/api';
+import { ScriptRoots } from '../utils/api.types';
+
+type ScriptType = 'manual' | 'automatic';
+
+interface CodeSaveButtonProps extends React.ComponentProps<typeof Button> {
+  code: string;
+}
+
+const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps }) => {
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [scriptType, setScriptType] = useState<ScriptType>('manual');
+  const [scriptName, setScriptName] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  const id = ScriptRoots[scriptType] + '/' + (scriptName.trim() || '<name>');
+
+  const handleOpen = () => setDialogOpen(true);
+  const handleClose = () => {
+    setDialogOpen(false);
+    setScriptName('');
+    setSaving(false);
+  };
+
+  const handleSave = async () => {
+    if (!scriptName.trim()) {
+      ToastQueue.negative('Script name is required');
+      return;
+    }
+    setSaving(true);
+    const path = ScriptRoots[scriptType] + scriptName.trim();
+    await toastRequest({
+      operation: 'Save script',
+      url: '/apps/acm/api/script.json',
+      method: 'POST',
+      data: {
+        id: path,
+        content: code,
+      },
+    });
+    ToastQueue.positive('Script saved successfully');
+    handleClose();
+  };
+
+  return (
+    <>
+      <Button onPress={handleOpen} {...buttonProps}>
+        <FileAdd />
+        <Text>Save</Text>
+      </Button>
+      <DialogContainer onDismiss={handleClose}>
+        {dialogOpen && (
+          <Dialog minWidth="40vw">
+            <Heading>Save Script</Heading>
+            <Divider />
+            <Content>
+              <Form validationBehavior="native">
+                <RadioGroup label="Type" isRequired value={scriptType} onChange={(value) => setScriptType(value as ScriptType)} orientation="horizontal">
+                  <Radio value="manual">
+                    <Hand size="XS" />
+                    <Text marginStart="size-50">Manual</Text>
+                  </Radio>
+                  <Radio value="automatic">
+                    <Launch size="XS" />
+                    <Text marginStart="size-50">Automatic</Text>
+                  </Radio>
+                </RadioGroup>
+                <TextField label="Name" width="100%" value={scriptName} onChange={setScriptName} isRequired marginTop="size-200" />
+                <TextField label="ID" width="100%" value={id} isDisabled marginTop="size-200" />
+              </Form>
+            </Content>
+            <ButtonGroup>
+              <Button variant="secondary" onPress={handleClose}>
+                <Close size="XS" />
+                <Text>Cancel</Text>
+              </Button>
+              <Button variant="cta" onPress={handleSave} isPending={saving}>
+                <Checkmark size="XS" />
+                <Text>Save</Text>
+              </Button>
+            </ButtonGroup>
+          </Dialog>
+        )}
+      </DialogContainer>
+    </>
+  );
+};
+
+export default CodeSaveButton;
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index a98227e9..e59dac38 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -5,6 +5,7 @@ import Print from '@spectrum-icons/workflow/Print';
 import { useEffect, useState } from 'react';
 import CodeEditor from '../components/CodeEditor.tsx';
 import CodeExecuteButton from '../components/CodeExecuteButton';
+import CodeSaveButton from '../components/CodeSaveButton';
 import CompilationStatus from '../components/CompilationStatus';
 import ConsoleHelpButton from '../components/ConsoleHelpButton';
 import ExecutionAbortButton from '../components/ExecutionAbortButton';
@@ -122,6 +123,7 @@ const ConsolePage = () => {
                 <Flex flex="1" alignItems="center">
                   <ButtonGroup>
                     <CodeExecuteButton code={code || ''} onDescribeFailed={onDescribeFailed} onExecute={onExecute} isPending={executing || compiling} isDisabled={pendingCompile || !!syntaxError || !!compileError} />
+                    <CodeSaveButton code={code || ''} variant="secondary" isDisabled={pendingCompile || !!syntaxError || !!compileError} />
                   </ButtonGroup>
                 </Flex>
                 <Flex flex="1" justifyContent="center" alignItems="center">
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index e6af6b99..9164f257 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -6,8 +6,15 @@ export type Executable = {
   arguments: ArgumentValues;
 };
 
-export const ScriptRootPath = '/conf/acm/settings/script';
-export const ConsoleDefaultScriptPath = `${ScriptRootPath}/template/core/console.groovy`;
+export const ScriptRoot = '/conf/acm/settings/script';
+export const ScriptRootManual = '/conf/acm/settings/script/manual';
+export const ScriptRootAutomatic = '/conf/acm/settings/script/automatic';
+export const ScriptRoots: Record<ScriptType, string> = {
+  manual: ScriptRootManual,
+  automatic: ScriptRootAutomatic,
+};
+
+export const ConsoleDefaultScriptPath = `${ScriptRoot}/template/core/console.groovy`;
 export const ConsoleDefaultScriptContent = `
 boolean canRun() {
   return conditions.always()
@@ -25,7 +32,7 @@ export function isExecutableConsole(id: string): boolean {
 }
 
 export function isExecutableScript(id: string): boolean {
-  return id.startsWith(ScriptRootPath);
+  return id.startsWith(ScriptRoot);
 }
 
 export type Description = {

From 85bb8a217132f01767bd2ddcbc03a42842987db4 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 08:27:33 +0200
Subject: [PATCH 70/83] Code save

---
 .../es/acm/core/code/script/ScriptUtils.java  |  2 ++
 .../vml/es/acm/core/event/ResourceEvent.java  |  1 +
 .../es/acm/core/script/ScriptRepository.java  | 22 ++++++++++++++
 .../es/acm/core/servlet/QueueCodeServlet.java |  1 -
 .../vml/es/acm/core/servlet/ScriptInput.java  | 17 +++++++++++
 .../es/acm/core/servlet/ScriptListOutput.java | 26 +++++++++++++++++
 .../vml/es/acm/core/servlet/ScriptOutput.java | 19 ++++--------
 .../es/acm/core/servlet/ScriptServlet.java    | 29 +++++++++++++++++--
 8 files changed, 99 insertions(+), 18 deletions(-)
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/ScriptInput.java
 create mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/ScriptListOutput.java

diff --git a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
index 8ebc4bd6..aa4903f5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/script/ScriptUtils.java
@@ -14,6 +14,8 @@
 
 public final class ScriptUtils {
 
+    public static final String MIME_TYPE = "text/x-groovy";
+
     private static final String DEF_TYPE = "java.lang.Object";
 
     private ScriptUtils() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
index fd321e6d..e6c57623 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -27,6 +27,7 @@ public ResourceEvent(Resource resource) {
         this.triggeredAt = resource.getValueMap().get(TRIGGERED_AT_PROP, Calendar.class);
     }
 
+    // TODO do not use repo resource to skip logging
     public static ResourceEvent create(String name, ResourceResolver resolver) {
         RepoResource result = RepoResource.of(resolver, String.format("%s/%s", ROOT, name));
         Map<String, Object> properties = new HashMap<>();
diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index d0e7a2f9..2b163612 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -2,6 +2,9 @@
 
 import dev.vml.es.acm.core.AcmConstants;
 import dev.vml.es.acm.core.AcmException;
+import dev.vml.es.acm.core.code.Code;
+import dev.vml.es.acm.core.code.script.ScriptUtils;
+import dev.vml.es.acm.core.repo.RepoResource;
 import dev.vml.es.acm.core.util.ResourceSpliterator;
 import dev.vml.es.acm.core.util.ResourceUtils;
 import java.util.List;
@@ -54,4 +57,23 @@ private Resource getOrCreateRoot(ScriptType type) throws AcmException {
             throw new AcmException(String.format("Cannot create script root '%s'!", type.root()), e);
         }
     }
+
+    public Script save(Code code) {
+        return save(code.getId(), code.getContent());
+    }
+
+    // TODO do not use repo resource to skip logging
+    public Script save(String id, Object data) throws AcmException {
+        if (!ScriptType.byPath(id).isPresent()) {
+            throw new AcmException(String.format("Cannot save script '%s' at unsupported path!", id));
+        }
+        ;
+        if (read(id).isPresent()) {
+            throw new AcmException(String.format("Cannot save script '%s' as it already exists!", id));
+        }
+        RepoResource resource = RepoResource.of(resourceResolver, id);
+        resource.parent().ensureRegularFolder();
+        resource.saveFile(ScriptUtils.MIME_TYPE, data);
+        return read(id).orElseThrow(() -> new AcmException(String.format("Cannot read script '%s' after saving!", id)));
+    }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index db0b2a37..f4ffdfb8 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -50,7 +50,6 @@ public class QueueCodeServlet extends SlingAllMethodsServlet {
     @Override
     protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
         try {
-
             QueueCodeInput input = JsonUtils.read(request.getInputStream(), QueueCodeInput.class);
             if (input == null) {
                 respondJson(response, badRequest("Code input is not specified!"));
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptInput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptInput.java
new file mode 100644
index 00000000..fbd37da8
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptInput.java
@@ -0,0 +1,17 @@
+package dev.vml.es.acm.core.servlet;
+
+import dev.vml.es.acm.core.code.Code;
+import java.io.Serializable;
+
+public class ScriptInput implements Serializable {
+
+    private Code code;
+
+    public ScriptInput() {
+        // for deserialization
+    }
+
+    public Code getCode() {
+        return code;
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptListOutput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptListOutput.java
new file mode 100644
index 00000000..498cd7ee
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptListOutput.java
@@ -0,0 +1,26 @@
+package dev.vml.es.acm.core.servlet;
+
+import dev.vml.es.acm.core.script.Script;
+import dev.vml.es.acm.core.script.ScriptStats;
+import java.io.Serializable;
+import java.util.List;
+
+public class ScriptListOutput implements Serializable {
+
+    private final List<Script> list;
+
+    private final List<ScriptStats> stats;
+
+    public ScriptListOutput(List<Script> scripts, List<ScriptStats> stats) {
+        this.list = scripts;
+        this.stats = stats;
+    }
+
+    public List<Script> getList() {
+        return list;
+    }
+
+    public List<ScriptStats> getStats() {
+        return stats;
+    }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptOutput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptOutput.java
index 470c43dd..74bfc7e8 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptOutput.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptOutput.java
@@ -1,26 +1,17 @@
 package dev.vml.es.acm.core.servlet;
 
 import dev.vml.es.acm.core.script.Script;
-import dev.vml.es.acm.core.script.ScriptStats;
 import java.io.Serializable;
-import java.util.List;
 
 public class ScriptOutput implements Serializable {
 
-    private final List<Script> list;
+    private final Script script;
 
-    private final List<ScriptStats> stats;
-
-    public ScriptOutput(List<Script> scripts, List<ScriptStats> stats) {
-        this.list = scripts;
-        this.stats = stats;
-    }
-
-    public List<Script> getList() {
-        return list;
+    public ScriptOutput(Script script) {
+        this.script = script;
     }
 
-    public List<ScriptStats> getStats() {
-        return stats;
+    public Script getScript() {
+        return script;
     }
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
index d0876f03..04b004ce 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
@@ -1,14 +1,15 @@
 package dev.vml.es.acm.core.servlet;
 
-import static dev.vml.es.acm.core.util.ServletResult.error;
-import static dev.vml.es.acm.core.util.ServletResult.ok;
+import static dev.vml.es.acm.core.util.ServletResult.*;
 import static dev.vml.es.acm.core.util.ServletUtils.*;
 
+import dev.vml.es.acm.core.code.Code;
 import dev.vml.es.acm.core.gui.SpaSettings;
 import dev.vml.es.acm.core.script.Script;
 import dev.vml.es.acm.core.script.ScriptRepository;
 import dev.vml.es.acm.core.script.ScriptStats;
 import dev.vml.es.acm.core.script.ScriptType;
+import dev.vml.es.acm.core.util.JsonUtils;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
@@ -78,7 +79,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
                     .map(path -> ScriptStats.forCompletedByPath(request.getResourceResolver(), path, statsLimit))
                     .collect(Collectors.toList());
 
-            ScriptOutput output = new ScriptOutput(scripts, stats);
+            ScriptListOutput output = new ScriptListOutput(scripts, stats);
             respondJson(response, ok("Scripts listed successfully", output));
         } catch (Exception e) {
             LOG.error("Scripts cannot be read!", e);
@@ -88,4 +89,26 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
                             .trim()));
         }
     }
+
+    @Override
+    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
+        try {
+            ScriptInput input = JsonUtils.read(request.getInputStream(), ScriptInput.class);
+            if (input == null) {
+                respondJson(response, badRequest("Script input is not specified!"));
+                return;
+            }
+
+            Code code = input.getCode();
+
+            ScriptRepository repository = new ScriptRepository(request.getResourceResolver());
+            Script script = repository.save(code);
+
+            ScriptOutput output = new ScriptOutput(script);
+            respondJson(response, ok("Script saved successfully", output));
+        } catch (Exception e) {
+            LOG.error("Script cannot be saved!", e);
+            respondJson(response, error("Script cannot be saved! " + e.getMessage()));
+        }
+    }
 }

From 93d9757b538daa5c2a256faccf658855d499451f Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 08:34:30 +0200
Subject: [PATCH 71/83] Code save

---
 ui.frontend/src/components/CodeSaveButton.tsx | 32 +++++++++++--------
 1 file changed, 19 insertions(+), 13 deletions(-)

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index 93a15ab7..080eb365 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -8,6 +8,7 @@ import Launch from '@spectrum-icons/workflow/Launch';
 import React, { useState } from 'react';
 import { toastRequest } from '../utils/api';
 import { ScriptRoots } from '../utils/api.types';
+import { Strings } from '../utils/strings';
 
 type ScriptType = 'manual' | 'automatic';
 
@@ -21,7 +22,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
   const [scriptName, setScriptName] = useState('');
   const [saving, setSaving] = useState(false);
 
-  const id = ScriptRoots[scriptType] + '/' + (scriptName.trim() || '<name>');
+  const id = ScriptRoots[scriptType] + '/' + (Strings.removeEnd(scriptName.trim(), '.groovy') || '{name}') + '.groovy';
 
   const handleOpen = () => setDialogOpen(true);
   const handleClose = () => {
@@ -35,19 +36,24 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
       ToastQueue.negative('Script name is required');
       return;
     }
+
     setSaving(true);
-    const path = ScriptRoots[scriptType] + scriptName.trim();
-    await toastRequest({
-      operation: 'Save script',
-      url: '/apps/acm/api/script.json',
-      method: 'POST',
-      data: {
-        id: path,
-        content: code,
-      },
-    });
-    ToastQueue.positive('Script saved successfully');
-    handleClose();
+    try {
+      await toastRequest({
+        operation: 'Save script',
+        url: '/apps/acm/api/script.json',
+        method: 'POST',
+        data: {
+          code: {
+            id: id,
+            content: code,
+          }
+        },
+      });
+      handleClose();
+    } finally {
+      setSaving(false);
+    }
   };
 
   return (

From 02522de68a436b9342b228e8417141982d6c6096 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 10:14:29 +0200
Subject: [PATCH 72/83] Code save

---
 .../snippet/available/core/schedule/cron.yml  | 19 ++++++++-
 ui.frontend/src/components/CodeSaveButton.tsx | 42 +++++++++++++------
 ui.frontend/src/utils/api.types.ts            |  8 ++--
 ui.frontend/src/utils/strings.ts              |  6 +++
 4 files changed, 58 insertions(+), 17 deletions(-)

diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
index 7779d154..c025705e 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/schedule/cron.yml
@@ -7,4 +7,21 @@ documentation: |
 
   AEM uses the Quartz scheduler, and so does ACM. Both require a specific syntax for cron expressions.
   For details, refer to the [Quartz documentation](https://www.quartz-scheduler.org/documentation).
-  To generate valid cron expressions, you can use online tools such as [FreeFormatter](https://www.freeformatter.com/cron-expression-generator-quartz.html).
\ No newline at end of file
+  To generate valid cron expressions, you can use online tools such as [FreeFormatter](https://www.freeformatter.com/cron-expression-generator-quartz.html).
+  
+  Some examples:
+  
+  Every 10 minutes:
+  ```groovy
+  schedules.cron("0 0/10 * * * ?")
+  ```
+  
+  Every day at 10:00 AM:
+  ```groovy
+  schedules.cron("0 0 10 * * ?")
+  ```
+  
+  At a specific date and time (1st July 2024 at 10:00 AM):
+  ```groovy
+  schedules.cron("0 0 10 1 7 ? 2024")
+  ```
\ No newline at end of file
diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index 080eb365..c86229ad 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -7,24 +7,29 @@ import Hand from '@spectrum-icons/workflow/Hand';
 import Launch from '@spectrum-icons/workflow/Launch';
 import React, { useState } from 'react';
 import { toastRequest } from '../utils/api';
-import { ScriptRoots } from '../utils/api.types';
+import { ScriptRoots, ScriptType } from '../utils/api.types';
 import { Strings } from '../utils/strings';
 
-type ScriptType = 'manual' | 'automatic';
-
 interface CodeSaveButtonProps extends React.ComponentProps<typeof Button> {
   code: string;
 }
 
+function detectScriptType(code: string): ScriptType {
+  const automaticPattern = /(def|Schedule)\s+scheduleRun\s*(\(\s*\))?\s*\{/;
+  return automaticPattern.test(code) ? ScriptType.AUTOMATIC : ScriptType.MANUAL;
+}
+
 const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps }) => {
   const [dialogOpen, setDialogOpen] = useState(false);
-  const [scriptType, setScriptType] = useState<ScriptType>('manual');
+  const [scriptType, setScriptType] = useState<ScriptType>(detectScriptType(code));
   const [scriptName, setScriptName] = useState('');
   const [saving, setSaving] = useState(false);
 
-  const id = ScriptRoots[scriptType] + '/' + (Strings.removeEnd(scriptName.trim(), '.groovy') || '{name}') + '.groovy';
+  const scriptNameValid = Strings.checkFileName(scriptName);
+  const scriptId = ScriptRoots[scriptType] + '/' + (Strings.removeEnd(scriptName.trim(), '.groovy') || '{name}') + '.groovy';
 
   const handleOpen = () => setDialogOpen(true);
+
   const handleClose = () => {
     setDialogOpen(false);
     setScriptName('');
@@ -36,6 +41,10 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
       ToastQueue.negative('Script name is required');
       return;
     }
+    if (!scriptNameValid) {
+      ToastQueue.negative('Invalid script name');
+      return;
+    }
 
     setSaving(true);
     try {
@@ -45,9 +54,9 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
         method: 'POST',
         data: {
           code: {
-            id: id,
+            id: scriptId,
             content: code,
-          }
+          },
         },
       });
       handleClose();
@@ -70,17 +79,26 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
             <Content>
               <Form validationBehavior="native">
                 <RadioGroup label="Type" isRequired value={scriptType} onChange={(value) => setScriptType(value as ScriptType)} orientation="horizontal">
-                  <Radio value="manual">
+                  <Radio value={ScriptType.MANUAL}>
                     <Hand size="XS" />
                     <Text marginStart="size-50">Manual</Text>
                   </Radio>
-                  <Radio value="automatic">
+                  <Radio value={ScriptType.AUTOMATIC}>
                     <Launch size="XS" />
                     <Text marginStart="size-50">Automatic</Text>
                   </Radio>
                 </RadioGroup>
-                <TextField label="Name" width="100%" value={scriptName} onChange={setScriptName} isRequired marginTop="size-200" />
-                <TextField label="ID" width="100%" value={id} isDisabled marginTop="size-200" />
+                <TextField
+                  label="Name"
+                  width="100%"
+                  value={scriptName}
+                  onChange={setScriptName}
+                  isRequired
+                  marginTop="size-200"
+                  validationState={scriptName && !scriptNameValid ? 'invalid' : undefined}
+                  errorMessage={scriptName && !scriptNameValid ? 'Invalid filename' : undefined}
+                />
+                <TextField label="ID" width="100%" value={scriptId} isDisabled marginTop="size-200" />
               </Form>
             </Content>
             <ButtonGroup>
@@ -88,7 +106,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
                 <Close size="XS" />
                 <Text>Cancel</Text>
               </Button>
-              <Button variant="cta" onPress={handleSave} isPending={saving}>
+              <Button variant="cta" onPress={handleSave} isPending={saving} isDisabled={!scriptNameValid || saving}>
                 <Checkmark size="XS" />
                 <Text>Save</Text>
               </Button>
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts
index 9164f257..1a3dd6d0 100644
--- a/ui.frontend/src/utils/api.types.ts
+++ b/ui.frontend/src/utils/api.types.ts
@@ -7,11 +7,11 @@ export type Executable = {
 };
 
 export const ScriptRoot = '/conf/acm/settings/script';
-export const ScriptRootManual = '/conf/acm/settings/script/manual';
-export const ScriptRootAutomatic = '/conf/acm/settings/script/automatic';
 export const ScriptRoots: Record<ScriptType, string> = {
-  manual: ScriptRootManual,
-  automatic: ScriptRootAutomatic,
+  MANUAL: '/conf/acm/settings/script/manual',
+  AUTOMATIC: '/conf/acm/settings/script/automatic',
+  EXTENSION: '/conf/acm/settings/script/extension',
+  MOCK: '/conf/acm/settings/script/mock',
 };
 
 export const ConsoleDefaultScriptPath = `${ScriptRoot}/template/core/console.groovy`;
diff --git a/ui.frontend/src/utils/strings.ts b/ui.frontend/src/utils/strings.ts
index 79880c91..9066c487 100644
--- a/ui.frontend/src/utils/strings.ts
+++ b/ui.frontend/src/utils/strings.ts
@@ -47,4 +47,10 @@ export class Strings {
     }
     return text.split(search).join(replacement);
   }
+
+  // starts with a letter, allows letters, numbers, underscores, hyphens, no spaces
+  static checkFileName(name: string): boolean {
+    const FILENAME_REGEX = /^[a-zA-Z][\w-]*$/;
+    return FILENAME_REGEX.test(name.trim());
+  }
 }

From 68e65d697d585bf746f734ac23962837893681f8 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 10:28:41 +0200
Subject: [PATCH 73/83] Code save

---
 ui.frontend/src/components/CodeSaveButton.tsx |  4 ++--
 ui.frontend/src/utils/strings.ts              | 10 ++++++++--
 2 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index c86229ad..d7ce247d 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -25,7 +25,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
   const [scriptName, setScriptName] = useState('');
   const [saving, setSaving] = useState(false);
 
-  const scriptNameValid = Strings.checkFileName(scriptName);
+  const scriptNameValid = Strings.checkFilePath(scriptName);
   const scriptId = ScriptRoots[scriptType] + '/' + (Strings.removeEnd(scriptName.trim(), '.groovy') || '{name}') + '.groovy';
 
   const handleOpen = () => setDialogOpen(true);
@@ -96,7 +96,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
                   isRequired
                   marginTop="size-200"
                   validationState={scriptName && !scriptNameValid ? 'invalid' : undefined}
-                  errorMessage={scriptName && !scriptNameValid ? 'Invalid filename' : undefined}
+                  errorMessage={scriptName && !scriptNameValid ? 'Invalid file path' : undefined}
                 />
                 <TextField label="ID" width="100%" value={scriptId} isDisabled marginTop="size-200" />
               </Form>
diff --git a/ui.frontend/src/utils/strings.ts b/ui.frontend/src/utils/strings.ts
index 9066c487..089ae2c9 100644
--- a/ui.frontend/src/utils/strings.ts
+++ b/ui.frontend/src/utils/strings.ts
@@ -48,9 +48,15 @@ export class Strings {
     return text.split(search).join(replacement);
   }
 
-  // starts with a letter, allows letters, numbers, underscores, hyphens, no spaces
   static checkFileName(name: string): boolean {
+    if (!name || name.trim() !== name) return false;
     const FILENAME_REGEX = /^[a-zA-Z][\w-]*$/;
-    return FILENAME_REGEX.test(name.trim());
+    return FILENAME_REGEX.test(name);
+  }
+
+  static checkFilePath(path: string): boolean {
+    if (!path || path.trim() !== path) return false;
+    const FILEPATH_REGEX = /^([a-zA-Z][\w-]*\/)*[a-zA-Z][\w-]*$/;
+    return FILEPATH_REGEX.test(path);
   }
 }

From 6a6947533044affa60a21fa82fc729b9cf1ccb16 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 10:41:05 +0200
Subject: [PATCH 74/83] Code save

---
 ui.frontend/src/utils/strings.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui.frontend/src/utils/strings.ts b/ui.frontend/src/utils/strings.ts
index 089ae2c9..61e79bde 100644
--- a/ui.frontend/src/utils/strings.ts
+++ b/ui.frontend/src/utils/strings.ts
@@ -50,13 +50,13 @@ export class Strings {
 
   static checkFileName(name: string): boolean {
     if (!name || name.trim() !== name) return false;
-    const FILENAME_REGEX = /^[a-zA-Z][\w-]*$/;
+    const FILENAME_REGEX = /^[a-zA-Z][\w-]*(\.[a-zA-Z0-9]+)?$/;
     return FILENAME_REGEX.test(name);
   }
 
   static checkFilePath(path: string): boolean {
     if (!path || path.trim() !== path) return false;
-    const FILEPATH_REGEX = /^([a-zA-Z][\w-]*\/)*[a-zA-Z][\w-]*$/;
+    const FILEPATH_REGEX = /^([a-zA-Z][\w-]*\/)*[a-zA-Z][\w-]*(\.[a-zA-Z0-9]+)?$/;
     return FILEPATH_REGEX.test(path);
   }
 }

From 6c8881d08db29f23e00f8fe002935f91a4ce29f5 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 11:17:56 +0200
Subject: [PATCH 75/83] Code save

---
 .../available/core/general/demo_arguments.yml  |  3 ++-
 .../core/general/demo_hello_world.yml          |  2 +-
 .../available/core/general/demo_processing.yml |  2 +-
 .../core/general/demo_schedule_cron.yml        | 18 ++++++++++++++++++
 4 files changed, 22 insertions(+), 3 deletions(-)
 create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml

diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_arguments.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_arguments.yml
index eeeb7c82..13fa2e92 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_arguments.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_arguments.yml
@@ -14,5 +14,6 @@ content: |
     println "Hello ${arguments.value('name')} ${arguments.value('surname')}!"
   }
 documentation: |
-  Parametrized script - a skeleton for a script with arguments.<br>
+  A skeleton for a manual script with arguments.
+  
   The script will print a greeting message with the provided name and surname.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_hello_world.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_hello_world.yml
index 3e245241..c1187f52 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_hello_world.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_hello_world.yml
@@ -9,4 +9,4 @@ content: |
     println "Hello World!"
   }
 documentation: |
-  Default script - a skeleton for own scripts.
+  A skeleton for a minimal script.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
index c258afea..1c6bc072 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml
@@ -18,4 +18,4 @@ content: |
     println "Processing done"
   }
 documentation: |
-  Simulate a long running process.
+  A skeleton for a script that simulates a processing task.
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml
new file mode 100644
index 00000000..e9f8260b
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml
@@ -0,0 +1,18 @@
+group: General
+name: general_demo_schedule_cron
+content: |
+  def scheduleRun() {
+      return schedules.cron("0 0/5 * * * ?")
+  }
+  
+  boolean canRun() {
+    return conditions.always()
+  }
+    
+  void doRun() {
+    println "Hello! I am running every minute."
+  }
+documentation: |
+  A skeleton for an automatic script that runs every 5 minutes using a cron expression.
+  
+  The script will only print a message indicating that it is running.
\ No newline at end of file

From 6c0887fbf88cda432b86a50fe032a37be7e21a53 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 11:47:00 +0200
Subject: [PATCH 76/83] Code save

---
 ui.frontend/src/components/CodeSaveButton.tsx | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index d7ce247d..d107804f 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -65,6 +65,11 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
     }
   };
 
+  const handleFormSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    await handleSave();
+  };
+
   return (
     <>
       <Button onPress={handleOpen} {...buttonProps}>
@@ -77,7 +82,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
             <Heading>Save Script</Heading>
             <Divider />
             <Content>
-              <Form validationBehavior="native">
+              <Form validationBehavior="native" onSubmit={handleFormSubmit}>
                 <RadioGroup label="Type" isRequired value={scriptType} onChange={(value) => setScriptType(value as ScriptType)} orientation="horizontal">
                   <Radio value={ScriptType.MANUAL}>
                     <Hand size="XS" />
@@ -106,7 +111,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
                 <Close size="XS" />
                 <Text>Cancel</Text>
               </Button>
-              <Button variant="cta" onPress={handleSave} isPending={saving} isDisabled={!scriptNameValid || saving}>
+              <Button variant="cta" type="submit" isPending={saving} isDisabled={!scriptNameValid || saving} onPress={() => handleSave()}>
                 <Checkmark size="XS" />
                 <Text>Save</Text>
               </Button>

From 37409341de9c57d9e7509c806557143ebbab3bec Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 11:48:35 +0200
Subject: [PATCH 77/83] Code save

---
 .../main/java/dev/vml/es/acm/core/script/ScriptRepository.java   | 1 -
 1 file changed, 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index 2b163612..d74dd14d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -62,7 +62,6 @@ public Script save(Code code) {
         return save(code.getId(), code.getContent());
     }
 
-    // TODO do not use repo resource to skip logging
     public Script save(String id, Object data) throws AcmException {
         if (!ScriptType.byPath(id).isPresent()) {
             throw new AcmException(String.format("Cannot save script '%s' at unsupported path!", id));

From 63d4cec745a3b515e104b5289681a874aa3ed069 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 11:49:49 +0200
Subject: [PATCH 78/83] Code save

---
 ui.frontend/src/components/CodeSaveButton.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index d107804f..fd32f859 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -38,11 +38,11 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
 
   const handleSave = async () => {
     if (!scriptName.trim()) {
-      ToastQueue.negative('Script name is required');
+      ToastQueue.negative('Script name is required!');
       return;
     }
     if (!scriptNameValid) {
-      ToastQueue.negative('Invalid script name');
+      ToastQueue.negative('Script name is invalid!');
       return;
     }
 

From 15585c8b3668ed5de76c587e60995f5a286b5426 Mon Sep 17 00:00:00 2001
From: Krystian Panek
 <81212505+krystian-panek-vmltech@users.noreply.github.com>
Date: Wed, 20 Aug 2025 11:51:02 +0200
Subject: [PATCH 79/83] Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
 .../snippet/available/core/general/demo_schedule_cron.yml       | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml
index e9f8260b..ec94193e 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_schedule_cron.yml
@@ -10,7 +10,7 @@ content: |
   }
     
   void doRun() {
-    println "Hello! I am running every minute."
+    println "Hello! I am running every 5 minutes."
   }
 documentation: |
   A skeleton for an automatic script that runs every 5 minutes using a cron expression.

From c2038406aafb46ea38f839c5737e716d01cbb0cc Mon Sep 17 00:00:00 2001
From: Krystian Panek
 <81212505+krystian-panek-vmltech@users.noreply.github.com>
Date: Wed, 20 Aug 2025 11:51:41 +0200
Subject: [PATCH 80/83] Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
 ui.frontend/src/components/CodeSaveButton.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui.frontend/src/components/CodeSaveButton.tsx b/ui.frontend/src/components/CodeSaveButton.tsx
index fd32f859..df488041 100644
--- a/ui.frontend/src/components/CodeSaveButton.tsx
+++ b/ui.frontend/src/components/CodeSaveButton.tsx
@@ -111,7 +111,7 @@ const CodeSaveButton: React.FC<CodeSaveButtonProps> = ({ code, ...buttonProps })
                 <Close size="XS" />
                 <Text>Cancel</Text>
               </Button>
-              <Button variant="cta" type="submit" isPending={saving} isDisabled={!scriptNameValid || saving} onPress={() => handleSave()}>
+              <Button variant="cta" type="submit" isPending={saving} isDisabled={!scriptNameValid || saving}>
                 <Checkmark size="XS" />
                 <Text>Save</Text>
               </Button>

From e5d580ff5f53ed59a76465e1ffad0aa3460b4bbc Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 11:52:07 +0200
Subject: [PATCH 81/83] Code save

---
 .../main/java/dev/vml/es/acm/core/script/ScriptRepository.java  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
index d74dd14d..468b14cd 100644
--- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
+++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java
@@ -66,7 +66,7 @@ public Script save(String id, Object data) throws AcmException {
         if (!ScriptType.byPath(id).isPresent()) {
             throw new AcmException(String.format("Cannot save script '%s' at unsupported path!", id));
         }
-        ;
+
         if (read(id).isPresent()) {
             throw new AcmException(String.format("Cannot save script '%s' as it already exists!", id));
         }

From 9cdf5b36f38726f83963292475de966790c2926d Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 12:23:36 +0200
Subject: [PATCH 82/83] Code save / events with props

---
 .../java/dev/vml/es/acm/core/event/Event.java |  3 ++
 .../es/acm/core/event/EventDispatcher.java    |  3 +-
 .../vml/es/acm/core/event/EventManager.java   |  4 +-
 .../vml/es/acm/core/event/ResourceEvent.java  | 54 ++++++++++++++-----
 .../acm/core/event/ResourceEventManager.java  |  4 +-
 5 files changed, 52 insertions(+), 16 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/event/Event.java b/core/src/main/java/dev/vml/es/acm/core/event/Event.java
index dbca862e..7c0cc16e 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/Event.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/Event.java
@@ -1,10 +1,13 @@
 package dev.vml.es.acm.core.event;
 
 import java.util.Calendar;
+import java.util.Map;
 
 public interface Event {
 
     String getName();
 
+    Map<String, Object> getProperties();
+
     Calendar getTriggeredAt();
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
index 6be22514..25226777 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventDispatcher.java
@@ -3,6 +3,7 @@
 import dev.vml.es.acm.core.code.ExecutionHistory;
 import dev.vml.es.acm.core.code.ExecutionQueue;
 import dev.vml.es.acm.core.util.ResourceUtils;
+import java.util.Collections;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceResolverFactory;
@@ -28,7 +29,7 @@ public class EventDispatcher implements EventListener {
     private ResourceResolverFactory resourceResolverFactory;
 
     public void dispatch(EventType eventType) {
-        eventManager.triggerEvent(eventType.name());
+        eventManager.triggerEvent(eventType.name().toLowerCase(), Collections.emptyMap());
     }
 
     @Override
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java b/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
index 52b105d8..409767f3 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/EventManager.java
@@ -1,6 +1,8 @@
 package dev.vml.es.acm.core.event;
 
+import java.util.Map;
+
 public interface EventManager {
 
-    void triggerEvent(String name);
+    void triggerEvent(String name, Map<String, Object> properties);
 }
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
index e6c57623..05450ea1 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEvent.java
@@ -1,16 +1,18 @@
 package dev.vml.es.acm.core.event;
 
 import dev.vml.es.acm.core.AcmConstants;
-import dev.vml.es.acm.core.repo.RepoResource;
+import dev.vml.es.acm.core.AcmException;
+import dev.vml.es.acm.core.util.ResourceUtils;
 import java.util.Calendar;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.apache.jackrabbit.JcrConstants;
-import org.apache.sling.api.resource.Resource;
-import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.*;
+import org.apache.sling.jcr.resource.api.JcrResourceConstants;
 
 public class ResourceEvent implements Event {
 
@@ -22,30 +24,58 @@ public class ResourceEvent implements Event {
 
     private final Calendar triggeredAt;
 
+    private final Map<String, Object> properties;
+
     public ResourceEvent(Resource resource) {
         this.name = StringUtils.substringAfter(resource.getPath(), ROOT + "/");
         this.triggeredAt = resource.getValueMap().get(TRIGGERED_AT_PROP, Calendar.class);
+        this.properties = readProperties(resource);
+    }
+
+    private static Map<String, Object> readProperties(Resource resource) {
+        Map<String, Object> props = new HashMap<>(resource.getValueMap());
+        props.remove(TRIGGERED_AT_PROP);
+        return props;
     }
 
-    // TODO do not use repo resource to skip logging
-    public static ResourceEvent create(String name, ResourceResolver resolver) {
-        RepoResource result = RepoResource.of(resolver, String.format("%s/%s", ROOT, name));
-        Map<String, Object> properties = new HashMap<>();
-        properties.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
-        properties.put(TRIGGERED_AT_PROP, Calendar.getInstance());
-        result.parent().ensureRegularFolder();
-        result.save(properties);
-        return new ResourceEvent(result.require());
+    public static ResourceEvent create(String name, Map<String, Object> props, ResourceResolver resolver) {
+        try {
+            Resource root = ResourceUtils.ensure(resolver, ROOT, JcrResourceConstants.NT_SLING_FOLDER);
+            Resource result = root.getChild(name);
+
+            Map<String, Object> updatedProps = new HashMap<>();
+            updatedProps.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
+            updatedProps.putAll(props);
+            updatedProps.put(TRIGGERED_AT_PROP, Calendar.getInstance());
+
+            if (result == null) {
+                result = resolver.create(root, name, updatedProps);
+            } else {
+                ModifiableValueMap existingProps = Objects.requireNonNull(result.adaptTo(ModifiableValueMap.class));
+                existingProps.putAll(updatedProps);
+            }
+            resolver.commit();
+            return new ResourceEvent(result);
+        } catch (PersistenceException e) {
+            throw new AcmException(String.format("Cannot create event '%s'!", name));
+        }
     }
 
+    @Override
     public String getName() {
         return name;
     }
 
+    @Override
     public Calendar getTriggeredAt() {
         return triggeredAt;
     }
 
+    @Override
+    public Map<String, Object> getProperties() {
+        return properties;
+    }
+
     public String getPath() {
         return ROOT + "/" + name;
     }
diff --git a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
index 785abf18..d3bacc96 100644
--- a/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
+++ b/core/src/main/java/dev/vml/es/acm/core/event/ResourceEventManager.java
@@ -48,10 +48,10 @@ public class ResourceEventManager implements EventManager, ResourceChangeListene
     private final Collection<EventListener> listeners = new CopyOnWriteArrayList<>();
 
     @Override
-    public void triggerEvent(String name) {
+    public void triggerEvent(String name, Map<String, Object> properties) {
         try (ResourceResolver resourceResolver = ResourceUtils.contentResolver(resourceResolverFactory, null)) {
             LOG.debug("Triggering event: {}", name);
-            ResourceEvent event = ResourceEvent.create(name, resourceResolver);
+            ResourceEvent event = ResourceEvent.create(name, properties, resourceResolver);
             replicator.replicate(
                     resourceResolver.adaptTo(Session.class), ReplicationActionType.ACTIVATE, event.getPath());
             LOG.debug("Triggered event: {}", event);

From 17ce3975225dec8cc7e2f060d1f5d99789ef78f2 Mon Sep 17 00:00:00 2001
From: Krystian Panek <krystian.panek@vml.com>
Date: Wed, 20 Aug 2025 12:24:30 +0200
Subject: [PATCH 83/83] Code save / events with props

---
 .../java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java    | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index f4ffdfb8..7fb96da2 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -4,7 +4,6 @@
 import static dev.vml.es.acm.core.util.ServletUtils.*;
 
 import dev.vml.es.acm.core.code.*;
-import dev.vml.es.acm.core.event.EventDispatcher;
 import dev.vml.es.acm.core.util.JsonUtils;
 import java.io.IOException;
 import java.util.Collections;
@@ -41,9 +40,6 @@ public class QueueCodeServlet extends SlingAllMethodsServlet {
     @Reference
     private ExecutionQueue executionQueue;
 
-    @Reference
-    private EventDispatcher eventDispatcher;
-
     @Reference
     private Executor executor;
 
