From d556d81623b214447e5fea9aed9db488fb5c9e5b Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:01:04 +0200 Subject: [PATCH 1/3] feat(actions): add and update cron (scheduled) actions Platform API 1.150 added the cron action source: an action that launches its pipeline on a recurring schedule, set by a 5-field cron expression and an IANA timezone (UTC when omitted). tw could only create GitHub, Seqera Platform and bucket actions. - 'actions add cron --cron-expression [--timezone ]' sends CreateActionRequest.cron. - 'actions update --cron-expression/--timezone' sends a partial UpdateActionRequest.cron; omitted fields keep their stored value. The API answers 400 for a cron block on another source, which tw would hide behind its generic update error, so tw refuses it up front. - 'actions view' shows the expression, timezone and next execution. - 'actions list' already accepts a null endpoint, which cron actions always have; its test now covers a cron row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/actions/AddCmd.java | 2 + .../tower/cli/commands/actions/UpdateCmd.java | 12 +++ .../cli/commands/actions/add/AddCronCmd.java | 45 +++++++++++ .../cli/responses/actions/ActionsList.java | 2 +- .../cli/responses/actions/ActionsView.java | 6 ++ .../tower/cli/actions/ActionsCmdTest.java | 78 +++++++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/actions/add/AddCronCmd.java diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java index 025a65ac..eb26c1c9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/AddCmd.java @@ -18,6 +18,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.actions.add.AddBucketCmd; +import io.seqera.tower.cli.commands.actions.add.AddCronCmd; import io.seqera.tower.cli.commands.actions.add.AddGitHubCmd; import io.seqera.tower.cli.commands.actions.add.AddTowerCmd; import io.seqera.tower.cli.exceptions.ShowUsageException; @@ -32,6 +33,7 @@ subcommands = { AddGitHubCmd.class, AddTowerCmd.class, + AddCronCmd.class, AddBucketCmd.class, } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java index e4e6d6aa..1e4aec75 100644 --- a/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/actions/UpdateCmd.java @@ -28,6 +28,7 @@ import io.seqera.tower.model.ActionResponseDto; import io.seqera.tower.model.ActionSource; import io.seqera.tower.model.BucketActionRequest; +import io.seqera.tower.model.CronActionRequest; import io.seqera.tower.model.UpdateActionRequest; import io.seqera.tower.model.WorkflowLaunchRequest; import picocli.CommandLine; @@ -52,6 +53,12 @@ public class UpdateCmd extends AbstractActionsCmd { @CommandLine.Option(names = {"--new-name"}, description = "Updated action name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.") public String newName; + @CommandLine.Option(names = {"--cron-expression"}, description = "Schedule of a cron action, as a standard 5-field cron expression.") + public String cronExpression; + + @CommandLine.Option(names = {"--timezone"}, description = "IANA timezone the schedule of a cron action runs in.") + public String timezone; + @CommandLine.Option(names = {"--marker-file"}, description = "Marker file of a bucket action, relative to the data link path.") public String markerFile; @@ -115,6 +122,11 @@ protected Response exec() throws ApiException, IOException { request.setName(newName != null ? newName : actionName); request.setLaunch(workflowLaunchRequest); + if (cronExpression != null || timezone != null) { + requireSource(action, ActionSource.cron, "--cron-expression and --timezone"); + request.setCron(new CronActionRequest().expression(cronExpression).timezone(timezone)); + } + if (markerFile != null || events != null) { requireSource(action, ActionSource.bucket, "--marker-file and --events"); request.setBucket(new BucketActionRequest().markerFile(markerFile).events(events)); diff --git a/src/main/java/io/seqera/tower/cli/commands/actions/add/AddCronCmd.java b/src/main/java/io/seqera/tower/cli/commands/actions/add/AddCronCmd.java new file mode 100644 index 00000000..f7f2babd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/actions/add/AddCronCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.actions.add; + +import io.seqera.tower.model.ActionSource; +import io.seqera.tower.model.CreateActionRequest; +import io.seqera.tower.model.CronActionRequest; +import picocli.CommandLine; + +@CommandLine.Command( + name = "cron", + description = "Add a pipeline action triggered on a recurring schedule" +) +public class AddCronCmd extends AbstractAddCmd { + + @CommandLine.Option(names = {"--cron-expression"}, description = "Schedule as a standard 5-field cron expression (e.g., '0 2 * * *').", required = true) + public String cronExpression; + + @CommandLine.Option(names = {"--timezone"}, description = "IANA timezone the schedule runs in (e.g., Europe/London). Default: UTC.") + public String timezone; + + @Override + protected ActionSource getSource() { + return ActionSource.cron; + } + + @Override + protected void configureTrigger(CreateActionRequest request) { + request.setCron(new CronActionRequest().expression(cronExpression).timezone(timezone)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java index 9efd7b58..ebc34fe0 100644 --- a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java +++ b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsList.java @@ -69,7 +69,7 @@ public void toString(PrintWriter out) { actions.forEach(element -> { - // Arrays.asList, not List.of: bucket actions have no endpoint until provisioned. + // Arrays.asList, not List.of: cron actions, and bucket actions until provisioned, have no endpoint. List rows = new ArrayList<>(Arrays.asList( formatActionId(element.getId(), baseWorkspaceUrl), element.getName(), diff --git a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java index 8628dbb6..467acb17 100644 --- a/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java +++ b/src/main/java/io/seqera/tower/cli/responses/actions/ActionsView.java @@ -25,6 +25,7 @@ import io.seqera.tower.cli.utils.TableList; import io.seqera.tower.model.ActionResponseDto; import io.seqera.tower.model.BucketActionConfig; +import io.seqera.tower.model.CronActionConfig; import io.seqera.tower.model.WorkflowLaunchRequest; import java.io.PrintWriter; @@ -80,6 +81,11 @@ public void toString(PrintWriter out) { private void addTriggerRows(TableList table) { switch (action.getConfig()) { + case CronActionConfig cron -> { + table.addRow("Cron expression", cron.getExpression()); + table.addRow("Timezone", cron.getTimezone()); + table.addRow("Next execution", FormatHelper.formatTime(action.getNextExecution())); + } case BucketActionConfig bucket -> { table.addRow("Data link ID", bucket.getDataLinkId()); table.addRow("Bucket", bucket.getBucketName()); diff --git a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java index b4056989..7d154581 100644 --- a/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/actions/ActionsCmdTest.java @@ -794,6 +794,68 @@ void testPauseError(MockServerClient mock) throws IOException { assertEquals(errorMessage(out.app, new TowerException(String.format("An error has occur while setting the action '%s' to '%s'", "hello", "PAUSE"))), out.stdErr); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testAddCron(OutputType format, MockServerClient mock) { + mock.reset(); + mockPrimaryComputeEnv(mock); + + mock.when( + request().withMethod("POST").withPath("/actions") + .withBody(json("{\"name\": \"nightly\", \"source\": \"cron\", \"cron\": {\"expression\": \"0 2 * * *\", \"timezone\": \"Europe/London\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("/actions/action_add")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "actions", "add", "cron", "-n", "nightly", "--pipeline", "https://github.com/pditommaso/nf-sleep", "--cron-expression", "0 2 * * *", "--timezone", "Europe/London"); + assertOutput(format, out, new ActionAdd("nightly", USER_WORKSPACE_NAME, "2Z1g6MCWpOLgHLA65cw1qt")); + } + + @Test + void testUpdateCron(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "cron", CRON_CONFIG); + + mock.when( + request().withMethod("PUT").withPath("/actions/57byWxhmUDLLWIF4J97XEP") + .withBody(json("{\"name\": \"hello\", \"cron\": {\"expression\": \"*/15 * * * *\"}}", MatchType.ONLY_MATCHING_FIELDS)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--cron-expression", "*/15 * * * *"); + assertOutput(OutputType.console, out, new ActionUpdate("hello", USER_WORKSPACE_NAME, "57byWxhmUDLLWIF4J97XEP")); + } + + @Test + void testUpdateCronOptionsOnGithubAction(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "github", "{\"events\": [\"push\"], \"discriminator\": \"github\"}"); + + ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--timezone", "UTC"); + + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + assertEquals(errorMessage(out.app, new TowerException("Options --cron-expression and --timezone apply only to cron actions, but action 'hello' is a github action")), out.stdErr); + mock.verify(request().withMethod("PUT"), VerificationTimes.never()); + } + + @Test + void testViewCron(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "cron", CRON_CONFIG); + mockUserInfo(mock); + + ExecOut out = exec(mock, "actions", "view", "-n", "hello"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("Cron expression | 0 2 * * *"), out.stdOut); + assertTrue(out.stdOut.matches("(?s).*Timezone +\\| Europe/London.*"), out.stdOut); + } + @ParameterizedTest @EnumSource(OutputType.class) void testAddBucket(OutputType format, MockServerClient mock) { @@ -842,6 +904,18 @@ void testUpdateBucketOptionsOnGithubAction(MockServerClient mock) { mock.verify(request().withMethod("PUT"), VerificationTimes.never()); } + @Test + void testUpdateBucketOptionsOnCronAction(MockServerClient mock) { + mock.reset(); + mockActionLookup(mock, "cron", CRON_CONFIG); + + ExecOut out = exec(mock, "actions", "update", "-n", "hello", "--events", "object:created"); + + assertEquals(1, out.exitCode); + assertEquals(errorMessage(out.app, new TowerException("Options --marker-file and --events apply only to bucket actions, but action 'hello' is a cron action")), out.stdErr); + mock.verify(request().withMethod("PUT"), VerificationTimes.never()); + } + @Test void testViewBucket(MockServerClient mock) { mock.reset(); @@ -869,6 +943,7 @@ void testListNewSources(MockServerClient mock) { request().withMethod("GET").withPath("/actions"), exactly(1) ).respond( response().withStatusCode(200).withBody("{\"actions\": [" + + "{\"id\": \"1a\", \"name\": \"nightly\", \"source\": \"cron\", \"status\": \"ACTIVE\", \"endpoint\": null, \"config\": " + CRON_CONFIG + "}," + "{\"id\": \"2b\", \"name\": \"on-upload\", \"source\": \"bucket\", \"status\": \"ERROR\", \"endpoint\": null, \"config\": " + BUCKET_CONFIG + "}" + "]}").withContentType(MediaType.APPLICATION_JSON) ); @@ -877,9 +952,12 @@ void testListNewSources(MockServerClient mock) { assertEquals("", out.stdErr); assertEquals(0, out.exitCode); + assertTrue(out.stdOut.matches("(?s).*nightly .*cron.*"), out.stdOut); assertTrue(out.stdOut.matches("(?s).*on-upload .*bucket.*"), out.stdOut); } + private static final String CRON_CONFIG = "{\"expression\": \"0 2 * * *\", \"timezone\": \"Europe/London\", \"discriminator\": \"cron\"}"; + private void mockPrimaryComputeEnv(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) From 92d63cf0e82415835272aa05adf2910390492b74 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:53:06 +0200 Subject: [PATCH 2/3] docs: document the cron action source added for API 1.150 Extend the 'Other additions' bullet for new action sources with cron. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/USAGE.md b/USAGE.md index 22ddbad2..d6c1af6f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -765,7 +765,7 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s - `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. -- `tw actions add bucket`. +- `tw actions add cron|bucket`. - `tw studios extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. - `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. From 983d8d78898ccd2e2a29346c3bced76996959153 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 18:53:18 +0200 Subject: [PATCH 3/3] build(native): register reflection metadata for the cron action command Entry for 'actions add cron', copied from the catch-up branch where a full test run under the GraalVM tracing agent captured it. The cron model classes were already registered. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index a70a361b..d55fccd0 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -384,6 +384,13 @@ "fields":[{"name":"dataLinkId"}, {"name":"events"}, {"name":"markerFile"}], "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.actions.add.AddCronCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"cronExpression"}, {"name":"timezone"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.actions.add.AddGitHubCmd", "allDeclaredFields":true,