From 1eaca633848fa2604704924e996e885a12ee131a Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:14 +0200 Subject: [PATCH 1/9] fix(build): allow PATCH requests on Java 25 Service account update is the first PATCH call in tw. The SDK's ApiClient enables Jersey's SET_METHOD_WORKAROUND, which sets HttpURLConnection.method (and HttpsURLConnectionImpl.delegate for HTTPS) by reflection. On Java 25 that throws InaccessibleObjectException, which tw reported as a connection error. Open both packages to the test and run JVMs, and to java -jar tw.jar through the Add-Opens manifest attribute. The tracing-agent blocks now append to the JVM args instead of replacing them. The native image needs reflection entries for the same fields (next commit). Co-Authored-By: Claude Opus 5.5 (1M context) --- build.gradle | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 9bd3b130..1bea4431 100644 --- a/build.gradle +++ b/build.gradle @@ -82,8 +82,13 @@ license { exclude('conf/**') } +// Jersey's HttpURLConnection connector can only send PATCH by reflecting into these JDK internals +// (HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, enabled by the SDK's ApiClient) +def jerseyPatchOpens = ['java.base/java.net', 'java.base/sun.net.www.protocol.https'] + application { mainClass.set('io.seqera.tower.cli.Tower') + applicationDefaultJvmArgs = jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" } } java { @@ -158,7 +163,7 @@ tasks.named('run') { // Run with GraalVM Tracing Agent enabled if (System.getProperty('tracing-agent')?.toBoolean()) { - jvmArgs = ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] + jvmArgs += ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] } } @@ -171,6 +176,9 @@ tasks.register('runReflectionConfigGenerator', JavaExec) { } tasks.named('shadowJar') { + manifest { + attributes 'Add-Opens': jerseyPatchOpens.join(' ') + } archiveBaseName.set('tw') archiveClassifier.set('') archiveVersion.set('') @@ -180,9 +188,10 @@ tasks.named('test') { // Use junit platform for unit tests useJUnitPlatform() dependsOn tasks.named('checkLicenses') + jvmArgs jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" } // Run tests with GraalVM Tracing Agent enabled if (System.getProperty('tracing-agent')?.toBoolean()) { - jvmArgs = ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] + jvmArgs += ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] } } From a1d96064f690b68f385bb07c7a26bd4173fc8eb2 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:15 +0200 Subject: [PATCH 2/9] fix(build): open the JDK packages for PATCH in the native image too Native images enforce module encapsulation for setAccessible as well. Even with reflection entries for HttpURLConnection.method and HttpsURLConnectionImpl.delegate, the native binary failed with 'module java.base does not opens java.net'. Pass the same --add-opens to native-image, and register both fields in reflect-config.json: the tracing agent cannot record them, because its access filter only keeps io.seqera callers. Verified with the native binary: service-accounts update sends PATCH over HTTP (MockServer suite) and over HTTPS (local TLS server). Co-Authored-By: Claude Opus 5.5 (1M context) --- build.gradle | 2 ++ conf/reflect-config.json | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/build.gradle b/build.gradle index 1bea4431..f235f77d 100644 --- a/build.gradle +++ b/build.gradle @@ -113,6 +113,8 @@ graalvmNative { buildArgs.add('-H:+AddAllCharsets') buildArgs.add('-H:EnableURLProtocols=https,http') buildArgs.add('-H:+ReportExceptionStackTraces') + // Native images enforce module encapsulation for setAccessible too, so the reflection entries alone are not enough + buildArgs.addAll(jerseyPatchOpens.collect { "--add-opens=${it}=ALL-UNNAMED" }) javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c144d657..2b031cd6 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -6507,6 +6507,10 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"java.net.HttpURLConnection", + "fields":[{"name":"method"}] +}, { "name":"java.net.UnknownHostException", "methods":[{"name":"","parameterTypes":["java.lang.String"] }] @@ -7095,6 +7099,10 @@ "name":"sun.net.www.protocol.http.ntlm.NTLMAuthentication", "methods":[{"name":"","parameterTypes":["boolean","java.lang.String","int","java.net.PasswordAuthentication"] }, {"name":"","parameterTypes":["boolean","java.net.URL","java.net.PasswordAuthentication"] }, {"name":"isTrustedSite","parameterTypes":["java.net.URL"] }, {"name":"supportsTransparentAuth","parameterTypes":[] }] }, +{ + "name":"sun.net.www.protocol.https.HttpsURLConnectionImpl", + "fields":[{"name":"delegate"}] +}, { "name":"sun.security.pkcs12.PKCS12KeyStore", "methods":[{"name":"","parameterTypes":[] }] From d1d6f3fed8e7dfdd5167b2dd0a93d4664273d975 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:15 +0200 Subject: [PATCH 3/9] feat(service-accounts): add service account commands Platform API 1.230.0 (PLAT-6535) made the organization service account endpoints generally available, but tw had no way to manage them. Add 'tw service-accounts' with list, add, view, update and delete over /orgs/{orgId}/service-accounts. 'list --workspace' uses /orgs/{orgId}/workspaces/{workspaceId}/service-accounts to show the accounts assigned to one workspace; that endpoint pages with an opaque token, so every page is fetched and --offset/--max are applied locally. Members of the new OrgRole.service_account now render as SERVICE_ACCOUNT in 'tw members list' instead of the raw enum value. Note: update uses PATCH, which Jersey's HttpURLConnection connector only sends through a reflective workaround that needs --add-opens java.base/java.net=ALL-UNNAMED on Java 16+. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/io/seqera/tower/cli/Tower.java | 2 + .../tower/cli/commands/AbstractApiCmd.java | 6 + .../cli/commands/ServiceAccountsCmd.java | 38 +++ .../AbstractServiceAccountsCmd.java | 57 +++++ .../cli/commands/serviceaccounts/AddCmd.java | 54 +++++ .../commands/serviceaccounts/DeleteCmd.java | 48 ++++ .../cli/commands/serviceaccounts/ListCmd.java | 88 +++++++ .../ServiceAccountRefOptions.java | 35 +++ .../commands/serviceaccounts/UpdateCmd.java | 60 +++++ .../cli/commands/serviceaccounts/ViewCmd.java | 43 ++++ .../serviceaccounts/ServiceAccountAdded.java | 36 +++ .../ServiceAccountDeleted.java | 36 +++ .../ServiceAccountUpdated.java | 36 +++ .../serviceaccounts/ServiceAccountView.java | 50 ++++ .../serviceaccounts/ServiceAccountsList.java | 74 ++++++ .../seqera/tower/cli/utils/FormatHelper.java | 2 + .../ServiceAccountsCmdTest.java | 219 ++++++++++++++++++ 17 files changed, 884 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java create mode 100644 src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 4cf2f248..2c3d00d1 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -37,6 +37,7 @@ import io.seqera.tower.cli.commands.PipelineSchemasCmd; import io.seqera.tower.cli.commands.RunsCmd; import io.seqera.tower.cli.commands.SecretsCmd; +import io.seqera.tower.cli.commands.ServiceAccountsCmd; import io.seqera.tower.cli.commands.TeamsCmd; import io.seqera.tower.cli.commands.WorkspacesCmd; import io.seqera.tower.cli.commands.enums.OutputType; @@ -76,6 +77,7 @@ TeamsCmd.class, WorkspacesCmd.class, SecretsCmd.class, + ServiceAccountsCmd.class, } ) public class Tower extends AbstractCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 20d15b2d..fcd3bafe 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -33,6 +33,7 @@ import io.seqera.tower.api.PipelineVersionsApi; import io.seqera.tower.api.PipelinesApi; import io.seqera.tower.api.PlatformsApi; +import io.seqera.tower.api.ServiceAccountsApi; import io.seqera.tower.api.ServiceInfoApi; import io.seqera.tower.api.StudiosApi; import io.seqera.tower.api.TeamsApi; @@ -122,6 +123,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private PipelineSecretsApi pipelineSecretsApi; private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; + private ServiceAccountsApi serviceAccountsApi; private ServiceInfoApi serviceInfoApi; private StudiosApi studiosApi; private TeamsApi teamsApi; @@ -248,6 +250,10 @@ protected PlatformsApi platformsApi() throws ApiException { return platformsApi == null ? new PlatformsApi(apiClient()) : platformsApi; } + protected ServiceAccountsApi serviceAccountsApi() throws ApiException { + return serviceAccountsApi == null ? new ServiceAccountsApi(apiClient()) : serviceAccountsApi; + } + protected ServiceInfoApi serviceInfoApi() throws ApiException { return serviceInfoApi == null ? new ServiceInfoApi(apiClient()) : serviceInfoApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java b/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java new file mode 100644 index 00000000..ac973bab --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/ServiceAccountsCmd.java @@ -0,0 +1,38 @@ +/* + * 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; + +import io.seqera.tower.cli.commands.serviceaccounts.AddCmd; +import io.seqera.tower.cli.commands.serviceaccounts.DeleteCmd; +import io.seqera.tower.cli.commands.serviceaccounts.ListCmd; +import io.seqera.tower.cli.commands.serviceaccounts.UpdateCmd; +import io.seqera.tower.cli.commands.serviceaccounts.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "service-accounts", + description = "Manage organization service accounts", + subcommands = { + ListCmd.class, + AddCmd.class, + ViewCmd.class, + UpdateCmd.class, + DeleteCmd.class, + } +) +public class ServiceAccountsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java new file mode 100644 index 00000000..6ed238ad --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AbstractServiceAccountsCmd.java @@ -0,0 +1,57 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.ListServiceAccountsResponse; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; + +import java.util.List; + +@Command +public abstract class AbstractServiceAccountsCmd extends AbstractApiCmd { + + public static final String ORGANIZATION_DESCRIPTION = "Organization name or numeric ID. Specify either the unique organization name or the numeric organization ID returned by 'tw organizations list'."; + + protected ServiceAccountDto fetchServiceAccount(Long orgId, ServiceAccountRefOptions ref) throws ApiException { + if (ref.serviceAccount.id != null) { + return serviceAccountsApi().describeServiceAccount(orgId, ref.serviceAccount.id).getServiceAccount(); + } + return serviceAccountByName(orgId, ref.serviceAccount.name); + } + + private ServiceAccountDto serviceAccountByName(Long orgId, String name) throws ApiException { + int offset = 0; + while (true) { + ListServiceAccountsResponse page = serviceAccountsApi().listServiceAccounts(orgId, offset, PaginationOptions.MAX); + List items = page.getServiceAccounts(); + for (ServiceAccountDto sa : items) { + if (name.equals(sa.getName())) { + return sa; + } + } + offset += items.size(); + if (items.isEmpty() || offset >= page.getTotalSize()) { + throw new TowerException(String.format("Service account '%s' not found in organization '%d'", name, orgId)); + } + } + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java new file mode 100644 index 00000000..9075d855 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/AddCmd.java @@ -0,0 +1,54 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded; +import io.seqera.tower.model.CreateServiceAccountRequest; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "add", + description = "Add a service account" +) +public class AddCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Option(names = {"-n", "--name"}, description = "Service account name. Must be unique across all Platform users. Use only lowercase letters, numbers, and dashes (max 40 characters).", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Service account description (max 1000 characters).") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + + CreateServiceAccountRequest request = new CreateServiceAccountRequest() + .name(name) + .description(description); + + ServiceAccountDto sa = serviceAccountsApi().createServiceAccount(orgId, request).getServiceAccount(); + + return new ServiceAccountAdded(organizationRef, sa); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java new file mode 100644 index 00000000..8f59930e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/DeleteCmd.java @@ -0,0 +1,48 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "delete", + description = "Delete a service account" +) +public class DeleteCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ServiceAccountDto sa = fetchServiceAccount(orgId, ref); + + serviceAccountsApi().deleteServiceAccount(orgId, sa.getId()); + + return new ServiceAccountDeleted(organizationRef, sa); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java new file mode 100644 index 00000000..c9b6d488 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ListCmd.java @@ -0,0 +1,88 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListServiceAccountsResponse; +import io.seqera.tower.model.ListWorkspaceServiceAccountsResponse; +import io.seqera.tower.model.ServiceAccountDto; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.util.ArrayList; +import java.util.List; + +@Command( + name = "list", + description = "List service accounts" +) +public class ListCmd extends AbstractServiceAccountsCmd { + + @ArgGroup(multiplicity = "1") + Scope scope; + + static class Scope { + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION) + String organizationRef; + + @Option(names = {"-w", "--workspace"}, description = "Workspace numeric identifier or reference in OrganizationName/WorkspaceName format. Lists only the service accounts assigned to this workspace.") + String workspaceRef; + } + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + if (scope.organizationRef != null) { + Long orgId = findOrganizationByRef(scope.organizationRef).getOrgId(); + ListServiceAccountsResponse response = serviceAccountsApi().listServiceAccounts(orgId, offset, max); + return new ServiceAccountsList(scope.organizationRef, null, response.getServiceAccounts(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } + + Long wspId = workspaceId(scope.workspaceRef); + List all = listWorkspaceServiceAccounts(orgId(wspId), wspId); + // The workspace endpoint pages with an opaque token, so offset and max are applied to the full list + int from = Math.min(offset, all.size()); + int to = Math.min(from + max, all.size()); + return new ServiceAccountsList(orgName(wspId), workspaceName(wspId), all.subList(from, to), PaginationInfo.from(paginationOptions, (long) all.size())); + } + + private List listWorkspaceServiceAccounts(Long orgId, Long wspId) throws ApiException { + List result = new ArrayList<>(); + String pageToken = null; + do { + ListWorkspaceServiceAccountsResponse page = serviceAccountsApi().listWorkspaceServiceAccounts(orgId, wspId, pageToken, PaginationOptions.MAX); + page.getItems().forEach(it -> result.add(new ServiceAccountDto() + .id(it.getId()) + .name(it.getName()) + .description(it.getDescription()) + .createdAt(it.getCreatedAt()))); + pageToken = page.getNextPageToken(); + } while (pageToken != null); + return result; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java new file mode 100644 index 00000000..244dd229 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ServiceAccountRefOptions.java @@ -0,0 +1,35 @@ +/* + * 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.serviceaccounts; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class ServiceAccountRefOptions { + + @ArgGroup(multiplicity = "1") + public ServiceAccountRef serviceAccount; + + public static class ServiceAccountRef { + + @Option(names = {"-i", "--id"}, description = "Service account numeric identifier.") + public Long id; + + @Option(names = {"-n", "--name"}, description = "Service account name.") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java new file mode 100644 index 00000000..7e251c63 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/UpdateCmd.java @@ -0,0 +1,60 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated; +import io.seqera.tower.model.ServiceAccountDto; +import io.seqera.tower.model.UpdateServiceAccountRequest; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "update", + description = "Update a service account" +) +public class UpdateCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Option(names = {"--new-name"}, description = "New service account name. Must be unique across all Platform users.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "New service account description (max 1000 characters).") + public String description; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + ServiceAccountDto sa = fetchServiceAccount(orgId, ref); + + // PATCH semantics: fields left null are not changed + UpdateServiceAccountRequest request = new UpdateServiceAccountRequest() + .name(newName) + .description(description); + + ServiceAccountDto updated = serviceAccountsApi().updateServiceAccount(orgId, sa.getId(), request).getServiceAccount(); + + return new ServiceAccountUpdated(organizationRef, updated); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java new file mode 100644 index 00000000..3c359fa2 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/serviceaccounts/ViewCmd.java @@ -0,0 +1,43 @@ +/* + * 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.serviceaccounts; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View service account details" +) +public class ViewCmd extends AbstractServiceAccountsCmd { + + @Option(names = {"-o", "--organization"}, description = ORGANIZATION_DESCRIPTION, required = true) + public String organizationRef; + + @Mixin + ServiceAccountRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long orgId = findOrganizationByRef(organizationRef).getOrgId(); + return new ServiceAccountView(organizationRef, fetchServiceAccount(orgId, ref)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java new file mode 100644 index 00000000..75542d5d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountAdded.java @@ -0,0 +1,36 @@ +/* + * 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.responses.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountAdded extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountAdded(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' (%d) added to %s organization|@%n", serviceAccount.getName(), serviceAccount.getId(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java new file mode 100644 index 00000000..683ee14f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountDeleted.java @@ -0,0 +1,36 @@ +/* + * 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.responses.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountDeleted extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountDeleted(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' deleted at %s organization|@%n", serviceAccount.getName(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java new file mode 100644 index 00000000..0a6764bb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountUpdated.java @@ -0,0 +1,36 @@ +/* + * 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.responses.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.ServiceAccountDto; + +public class ServiceAccountUpdated extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountUpdated(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Service account '%s' updated at %s organization|@%n", serviceAccount.getName(), organizationName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java new file mode 100644 index 00000000..42690ca1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountView.java @@ -0,0 +1,50 @@ +/* + * 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.responses.serviceaccounts; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ServiceAccountDto; + +import java.io.PrintWriter; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class ServiceAccountView extends Response { + + public final String organizationName; + public final ServiceAccountDto serviceAccount; + + public ServiceAccountView(String organizationName, ServiceAccountDto serviceAccount) { + this.organizationName = organizationName; + this.serviceAccount = serviceAccount; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Service account at %s organization:|@%n", organizationName))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", serviceAccount.getId().toString()); + table.addRow("Name", serviceAccount.getName()); + table.addRow("Description", serviceAccount.getDescription()); + table.addRow("Member ID", serviceAccount.getMemberId() == null ? null : serviceAccount.getMemberId().toString()); + table.addRow("Created", formatDate(serviceAccount.getCreatedAt())); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java new file mode 100644 index 00000000..ff637936 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/serviceaccounts/ServiceAccountsList.java @@ -0,0 +1,74 @@ +/* + * 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.responses.serviceaccounts; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.ServiceAccountDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class ServiceAccountsList extends Response { + + public final String organizationName; + @Nullable + public final String workspaceName; + public final List serviceAccounts; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public ServiceAccountsList(String organizationName, @Nullable String workspaceName, List serviceAccounts, @Nullable PaginationInfo paginationInfo) { + this.organizationName = organizationName; + this.workspaceName = workspaceName; + this.serviceAccounts = serviceAccounts; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(workspaceName == null + ? String.format("%n @|bold Service accounts for %s organization:|@%n", organizationName) + : String.format("%n @|bold Service accounts for '%s/%s' workspace:|@%n", organizationName, workspaceName))); + + if (serviceAccounts == null || serviceAccounts.isEmpty()) { + out.println(ansi(" @|yellow No service accounts found|@")); + return; + } + + TableList table = new TableList(out, 4, "ID", "Name", "Description", "Created"); + table.setPrefix(" "); + serviceAccounts.forEach(sa -> table.addRow( + sa.getId().toString(), + sa.getName(), + sa.getDescription(), + formatDate(sa.getCreatedAt()) + )); + table.print(); + + PaginationInfo.addFooter(out, paginationInfo); + + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java index e9e1a85b..77cb50e6 100644 --- a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java @@ -331,6 +331,8 @@ public static String formatOrgRole(OrgRole role) { return ansi("@|fg(green) MEMBER|@"); case owner: return ansi("@|fg(magenta) OWNER|@"); + case service_account: + return ansi("@|fg(cyan) SERVICE_ACCOUNT|@"); default: return role.toString(); } diff --git a/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java b/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java new file mode 100644 index 00000000..2ab1a03a --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/serviceaccounts/ServiceAccountsCmdTest.java @@ -0,0 +1,219 @@ +/* + * 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.serviceaccounts; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView; +import io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList; +import io.seqera.tower.model.ServiceAccountDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.matchers.MatchType; +import org.mockserver.model.MediaType; + +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class ServiceAccountsCmdTest extends BaseCmdTest { + + private static final String ORG_PATH = "/orgs/27736513644467/service-accounts"; + + private static final String SA_LIST = """ + { + "serviceAccounts": [ + {"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z", "memberId": 21}, + {"id": 12, "name": "nightly", "description": null, "createdAt": "2026-09-02T10:00:00Z", "memberId": 22} + ], + "totalSize": 2 + } + """; + + private static final String CI_BOT = """ + {"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z", "memberId": 21} + """; + + @BeforeEach + void mockUserWorkspaces(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/user-info")) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces")) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private static ServiceAccountDto ciBot() throws JsonProcessingException { + return parseJson(CI_BOT, ServiceAccountDto.class); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH) + .withQueryStringParameter("offset", "0") + .withQueryStringParameter("max", "100"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "list", "-o", "organization1"); + + assertOutput(format, out, new ServiceAccountsList("organization1", null, + parseJson(SA_LIST, io.seqera.tower.model.ListServiceAccountsResponse.class).getServiceAccounts(), + null)); + } + + @Test + void testListWorkspaceFollowsPageToken(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/orgs/27736513644467/workspaces/75887156211589/service-accounts") + .withQueryStringParameter("pageToken", "next-1"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"items": [{"id": 12, "name": "nightly", "createdAt": "2026-09-02T10:00:00Z"}], "nextPageToken": null} + """).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("GET").withPath("/orgs/27736513644467/workspaces/75887156211589/service-accounts") + .withQueryStringParameter("pageSize", "100"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"items": [{"id": 11, "name": "ci-bot", "description": "CI pipelines", "createdAt": "2026-09-01T10:00:00Z"}], "nextPageToken": "next-1"} + """).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(OutputType.json, mock, "service-accounts", "list", "-w", "75887156211589"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(true, out.stdOut.contains("\"workspaceName\" : \"workspace1\""), out.stdOut); + assertEquals(true, out.stdOut.contains("\"name\" : \"ci-bot\""), out.stdOut); + assertEquals(true, out.stdOut.contains("\"name\" : \"nightly\""), out.stdOut); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("POST").withPath(ORG_PATH) + .withBody(json(""" + {"name": "ci-bot", "description": "CI pipelines"} + """, MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(201).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "add", "-o", "organization1", "-n", "ci-bot", "-d", "CI pipelines"); + + assertOutput(format, out, new ServiceAccountAdded("organization1", ciBot())); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewById(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "view", "-o", "organization1", "-i", "11"); + + assertOutput(format, out, new ServiceAccountView("organization1", ciBot())); + } + + @Test + void testViewByName(MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(OutputType.console, mock, "service-accounts", "view", "-o", "organization1", "-n", "ci-bot"); + + assertOutput(OutputType.console, out, new ServiceAccountView("organization1", ciBot())); + } + + @Test + void testViewByNameNotFound(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "service-accounts", "view", "-o", "organization1", "-n", "missing"); + + assertEquals(errorMessage(out.app, new TowerException("Service account 'missing' not found in organization '27736513644467'")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdateSendsOnlyChangedFields(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + CI_BOT + "}").withContentType(MediaType.APPLICATION_JSON) + ); + String updated = CI_BOT.replace("ci-bot", "ci-runner"); + mock.when( + request().withMethod("PATCH").withPath(ORG_PATH + "/11") + .withBody(json("{\"name\": \"ci-runner\"}", MatchType.STRICT)), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"serviceAccount\": " + updated + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "service-accounts", "update", "-o", "organization1", "-i", "11", "--new-name", "ci-runner"); + + assertOutput(format, out, new ServiceAccountUpdated("organization1", parseJson(updated, ServiceAccountDto.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDeleteByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when( + request().withMethod("GET").withPath(ORG_PATH), exactly(1) + ).respond( + response().withStatusCode(200).withBody(SA_LIST).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("DELETE").withPath(ORG_PATH + "/11"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "service-accounts", "delete", "-o", "organization1", "-n", "ci-bot"); + + assertOutput(format, out, new ServiceAccountDeleted("organization1", ciBot())); + } +} From f1bb1cae01adf1308bf1057b940a13e1a7100f29 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:44 +0200 Subject: [PATCH 4/9] feat(agents): add agents command Platform API 1.175.0 added workspace agents (CRUD, enable/disable), and 1.190.0 replaced their systemPrompt/templateId fields with agentInstructions/agentInstructionsTemplateId, but tw had no command for any of them. tw agents list/view/add/update/delete/enable/disable cover these operations against the 1.190 agent model. Agents are workspace-scoped and gated per organization by the agent configuration feature, so every command takes a required workspace. The update API replaces the whole agent, so update fetches the agent and resends unchanged fields. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/java/io/seqera/tower/cli/Tower.java | 2 + .../tower/cli/commands/AbstractApiCmd.java | 5 + .../seqera/tower/cli/commands/AgentsCmd.java | 43 ++++ .../commands/agents/AbstractAgentsCmd.java | 45 ++++ .../tower/cli/commands/agents/AddCmd.java | 62 +++++ .../agents/AgentInstructionsOptions.java | 38 +++ .../cli/commands/agents/AgentRefOptions.java | 36 +++ .../tower/cli/commands/agents/DeleteCmd.java | 47 ++++ .../tower/cli/commands/agents/DisableCmd.java | 47 ++++ .../tower/cli/commands/agents/EnableCmd.java | 47 ++++ .../tower/cli/commands/agents/ListCmd.java | 56 +++++ .../tower/cli/commands/agents/UpdateCmd.java | 71 ++++++ .../tower/cli/commands/agents/ViewCmd.java | 44 ++++ .../cli/responses/agents/AgentAdded.java | 37 +++ .../cli/responses/agents/AgentDeleted.java | 36 +++ .../cli/responses/agents/AgentUpdated.java | 38 +++ .../tower/cli/responses/agents/AgentView.java | 56 +++++ .../cli/responses/agents/AgentsList.java | 69 ++++++ .../tower/cli/agents/AgentsCmdTest.java | 216 ++++++++++++++++++ 19 files changed, 995 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java create mode 100644 src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index 2c3d00d1..e7bbbce1 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -22,6 +22,7 @@ import io.seqera.tower.cli.commands.AbstractCmd; import io.seqera.tower.cli.commands.ActionsCmd; +import io.seqera.tower.cli.commands.AgentsCmd; import io.seqera.tower.cli.commands.CollaboratorsCmd; import io.seqera.tower.cli.commands.ComputeEnvsCmd; import io.seqera.tower.cli.commands.CredentialsCmd; @@ -58,6 +59,7 @@ description = "Seqera Platform CLI", subcommands = { ActionsCmd.class, + AgentsCmd.class, CollaboratorsCmd.class, ComputeEnvsCmd.class, CredentialsCmd.class, diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index fcd3bafe..84538916 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -19,6 +19,7 @@ import io.seqera.tower.ApiClient; import io.seqera.tower.ApiException; import io.seqera.tower.api.ActionsApi; +import io.seqera.tower.api.AgentsApi; import io.seqera.tower.api.AvatarsApi; import io.seqera.tower.api.ComputeEnvsApi; import io.seqera.tower.api.CredentialsApi; @@ -194,6 +195,10 @@ protected ActionsApi actionsApi() throws ApiException { return actionsApi == null ? new ActionsApi(apiClient()) : actionsApi; } + protected AgentsApi agentsApi() throws ApiException { + return new AgentsApi(apiClient()); + } + protected AvatarsApi avatarsApi() throws ApiException { return avatarsApi == null ? new AvatarsApi(apiClient()) : avatarsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java new file mode 100644 index 00000000..67e8f3fb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java @@ -0,0 +1,43 @@ +/* + * 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; + +import io.seqera.tower.cli.commands.agents.AddCmd; +import io.seqera.tower.cli.commands.agents.DeleteCmd; +import io.seqera.tower.cli.commands.agents.DisableCmd; +import io.seqera.tower.cli.commands.agents.EnableCmd; +import io.seqera.tower.cli.commands.agents.ListCmd; +import io.seqera.tower.cli.commands.agents.UpdateCmd; +import io.seqera.tower.cli.commands.agents.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "agents", + description = "Manage AI agents. Agents must be enabled for the workspace organization.", + subcommands = { + ListCmd.class, + ViewCmd.class, + AddCmd.class, + UpdateCmd.class, + DeleteCmd.class, + EnableCmd.class, + DisableCmd.class, + } +) +public class AgentsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.java new file mode 100644 index 00000000..9b536d4e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AbstractAgentsCmd.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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; + +@Command +public abstract class AbstractAgentsCmd extends AbstractApiCmd { + + // The API caps agent pages at 100 entries + private static final int MAX_PAGE_SIZE = 100; + + protected AgentDbDto fetchAgent(AgentRefOptions.AgentRef ref, Long wspId) throws ApiException { + String agentId = ref.id != null ? ref.id : agentByName(ref.name, wspId).getId(); + return agentsApi().describeAgent(agentId, wspId).getAgent(); + } + + private AgentDbDto agentByName(String name, Long wspId) throws ApiException { + for (AgentDbDto agent : agentsApi().listAgents(wspId, name, MAX_PAGE_SIZE, 0).getAgents()) { + if (name.equals(agent.getName())) { + return agent; + } + } + throw new TowerException(String.format("Unknown agent '%s' at %s workspace", name, workspaceRef(wspId))); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java new file mode 100644 index 00000000..b96d4450 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java @@ -0,0 +1,62 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentAdded; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.CreateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +@Command( + name = "add", + description = "Add an agent" +) +public class AddCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-n", "--name"}, description = "Agent name. Must be unique per workspace. Names consist of alphanumeric, hyphen, and underscore characters.", required = true) + public String name; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup(multiplicity = "1") + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + CreateAgentRequest request = new CreateAgentRequest() + .name(name) + .description(description) + .agentInstructions(instructions.read()); + + AgentDbDto agent = agentsApi().createAgent(request, wspId).getAgent(); + return new AgentAdded(workspaceRef(wspId), agent); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java new file mode 100644 index 00000000..6fe195dd --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentInstructionsOptions.java @@ -0,0 +1,38 @@ +/* + * 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.agents; + +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Path; + +import static io.seqera.tower.cli.utils.FilesHelper.readStringOrStdin; + +public class AgentInstructionsOptions { + + @Option(names = {"--instructions"}, description = "Agent instructions: the role, behavior, and constraints for the agent.") + public String text; + + @Option(names = {"--instructions-file"}, description = "File containing the agent instructions. Use '-' to read from stdin.") + public Path file; + + public String read() throws IOException { + return text != null ? text : readStringOrStdin(file); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java new file mode 100644 index 00000000..9e3db312 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AgentRefOptions.java @@ -0,0 +1,36 @@ +/* + * 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.agents; + +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public class AgentRefOptions { + + @ArgGroup(multiplicity = "1") + public AgentRef agent; + + public static class AgentRef { + + @Option(names = {"-i", "--id"}, description = "Agent identifier") + public String id; + + @Option(names = {"-n", "--name"}, description = "Agent name") + public String name; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java new file mode 100644 index 00000000..10795eee --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DeleteCmd.java @@ -0,0 +1,47 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "delete", + description = "Delete an agent" +) +public class DeleteCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().deleteAgent(agent.getId(), wspId); + return new AgentDeleted(workspaceRef(wspId), agent.getName()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java new file mode 100644 index 00000000..20c1a1be --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/DisableCmd.java @@ -0,0 +1,47 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "disable", + description = "Disable an active agent. A disabled agent cannot be launched." +) +public class DisableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().disableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "disabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java new file mode 100644 index 00000000..4c96068c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/EnableCmd.java @@ -0,0 +1,47 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "enable", + description = "Enable an inactive agent" +) +public class EnableCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + agentsApi().enableAgent(agent.getId(), wspId); + return new AgentUpdated(workspaceRef(wspId), agent.getName(), "enabled"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java new file mode 100644 index 00000000..f8c7416a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/ListCmd.java @@ -0,0 +1,56 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListAgentsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List agents" +) +public class ListCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-f", "--filter"}, description = "Filter agents by name") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListAgentsResponse response = agentsApi().listAgents(wspId, filter, max, offset); + + return new AgentsList(workspaceRef(wspId), response.getAgents(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java new file mode 100644 index 00000000..ad46ec7f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java @@ -0,0 +1,71 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.UpdateAgentRequest; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +import java.io.IOException; + +import static io.seqera.tower.cli.utils.ModelHelper.coalesce; + +@Command( + name = "update", + description = "Update an agent" +) +public class UpdateCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Option(names = {"--new-name"}, description = "New agent name. Names consist of alphanumeric, hyphen, and underscore characters.") + public String newName; + + @Option(names = {"-d", "--description"}, description = "Agent description (max 120 characters).") + public String description; + + @ArgGroup + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + AgentDbDto agent = fetchAgent(ref.agent, wspId); + + // The API replaces the whole agent, so unchanged fields are sent with their current value + UpdateAgentRequest request = new UpdateAgentRequest() + .name(coalesce(newName, agent.getName())) + .description(coalesce(description, agent.getDescription())) + .agentInstructions(instructions != null ? instructions.read() : agent.getAgentInstructions()) + .agentInstructionsTemplateId(agent.getAgentInstructionsTemplateId()); + + AgentDbDto updated = agentsApi().updateAgent(agent.getId(), request, wspId).getAgent(); + return new AgentUpdated(workspaceRef(wspId), updated.getName(), "updated"); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java new file mode 100644 index 00000000..b1605e6e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/ViewCmd.java @@ -0,0 +1,44 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command( + name = "view", + description = "View agent details" +) +public class ViewCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + AgentRefOptions ref; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + return new AgentView(workspaceRef(wspId), fetchAgent(ref.agent, wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java new file mode 100644 index 00000000..4e9d309f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentAdded.java @@ -0,0 +1,37 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.AgentDbDto; + +public class AgentAdded extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentAdded(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow New agent '%s' (%s) added at %s workspace|@%n", agent.getName(), agent.getId(), workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java new file mode 100644 index 00000000..3d912b89 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentDeleted.java @@ -0,0 +1,36 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentDeleted extends Response { + + public final String workspaceRef; + public final String agentName; + + public AgentDeleted(String workspaceRef, String agentName) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' deleted at %s workspace|@%n", agentName, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java new file mode 100644 index 00000000..c2cb9d7c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentUpdated.java @@ -0,0 +1,38 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentUpdated extends Response { + + public final String workspaceRef; + public final String agentName; + public final String action; + + public AgentUpdated(String workspaceRef, String agentName, String action) { + this.workspaceRef = workspaceRef; + this.agentName = agentName; + this.action = action; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent '%s' %s at %s workspace|@%n", agentName, action, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java new file mode 100644 index 00000000..48c85352 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java @@ -0,0 +1,56 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentDbDto; + +import java.io.PrintWriter; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; + +public class AgentView extends Response { + + public final String workspaceRef; + public final AgentDbDto agent; + + public AgentView(String workspaceRef, AgentDbDto agent) { + this.workspaceRef = workspaceRef; + this.agent = agent; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent at %s workspace:|@%n", workspaceRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", agent.getId()); + table.addRow("Name", agent.getName()); + table.addRow("Description", Objects.toString(agent.getDescription(), "")); + table.addRow("Status", agent.getStatus() == null ? "NA" : agent.getStatus().getValue()); + table.addRow("Created", formatDate(agent.getDateCreated())); + table.addRow("Updated", formatDate(agent.getLastUpdated())); + table.print(); + + out.println(ansi(String.format("%n @|bold Instructions:|@%n"))); + out.println(agent.getAgentInstructions()); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java new file mode 100644 index 00000000..1a5082b4 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentsList.java @@ -0,0 +1,69 @@ +/* + * 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.responses.agents; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentDbDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; +import static io.seqera.tower.cli.utils.FormatHelper.formatDescription; + +public class AgentsList extends Response { + + public final String workspaceRef; + public final List agents; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public AgentsList(String workspaceRef, List agents, @Nullable PaginationInfo paginationInfo) { + this.workspaceRef = workspaceRef; + this.agents = agents; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agents at %s workspace:|@%n", workspaceRef))); + if (agents == null || agents.isEmpty()) { + out.println(ansi(" @|yellow No agents found|@")); + return; + } + + TableList table = new TableList(out, 5, "ID", "Name", "Description", "Status", "Updated"); + table.setPrefix(" "); + agents.forEach(agent -> table.addRow( + agent.getId(), + agent.getName(), + formatDescription(agent.getDescription(), 100), + agent.getStatus() == null ? "NA" : agent.getStatus().getValue(), + formatDate(agent.getLastUpdated()) + )); + table.print(); + PaginationInfo.addFooter(out, paginationInfo); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java new file mode 100644 index 00000000..2d91a3ae --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java @@ -0,0 +1,216 @@ +/* + * 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.agents; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.agents.AgentAdded; +import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.cli.responses.agents.AgentUpdated; +import io.seqera.tower.cli.responses.agents.AgentView; +import io.seqera.tower.cli.responses.agents.AgentsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.AgentDbDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.MediaType; + +import java.io.IOException; +import java.util.List; + +import static io.seqera.tower.cli.utils.JsonHelper.parseJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class AgentsCmdTest extends BaseCmdTest { + + private static final String WSP_ID = "75887156211589"; + private static final String WSP_REF = "[organization1 / workspace1]"; + + private static final String AGENT = """ + { + "id": "agt_1", + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause", + "agentInstructionsTemplateId": "debug-template", + "status": "active", + "workspaceId": 75887156211589, + "createdBy": 1264, + "dateCreated": "2026-09-01T10:00:00Z", + "lastUpdated": "2026-09-02T10:00:00Z" + } + """; + + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + mock.when(request().withMethod("GET").withPath("/user-info"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON)); + mock.when(request().withMethod("GET").withPath("/user/1264/workspaces"), exactly(1)) + .respond(response().withStatusCode(200).withBody(loadResource("workspaces/workspaces_list")).withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockDescribe(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + } + + private void mockFindByName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix-failed-runs") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT.replace("fix-failed-runs", "fix-failed-runs-v2").replace("agt_1", "agt_2") + "," + AGENT + "],\"totalSize\":2}").withContentType(MediaType.APPLICATION_JSON)); + } + + private static AgentDbDto agent() throws JsonProcessingException { + return parseJson(AGENT, AgentDbDto.class); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testList(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/agents") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "fix") + .withQueryStringParameter("max", "10") + .withQueryStringParameter("offset", "10"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[" + AGENT + "],\"totalSize\":11}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "list", "-w", WSP_ID, "-f", "fix", "--max", "10", "--page", "2"); + + assertOutput(format, out, new AgentsList(WSP_REF, List.of(agent()), PaginationInfo.from(null, 10, 2, 11L))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewByName(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockFindByName(mock); + mockDescribe(mock); + + ExecOut out = exec(format, mock, "agents", "view", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentView(WSP_REF, agent())); + } + + @Test + void testViewUnknownName(MockServerClient mock) { + mock.when(request().withMethod("GET").withPath("/agents").withQueryStringParameter("search", "missing"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agents\":[],\"totalSize\":0}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(mock, "agents", "view", "-w", WSP_ID, "-n", "missing"); + + assertEquals(errorMessage(out.app, new TowerException("Unknown agent 'missing' at " + WSP_REF + " workspace")), out.stdErr); + assertEquals(1, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testAdd(OutputType format, MockServerClient mock) throws IOException { + mock.when(request().withMethod("POST").withPath("/agents").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "fix-failed-runs", + "description": "Debug failed runs", + "agentInstructions": "Find the root cause" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + String instructions = tempFile("Find the root cause", "instructions", ".md"); + ExecOut out = exec(format, mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs", "-d", "Debug failed runs", + "--instructions-file", instructions); + + assertOutput(format, out, new AgentAdded(WSP_REF, agent())); + } + + @Test + void testAddRequiresInstructions(MockServerClient mock) { + ExecOut out = exec(mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertEquals(2, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdate(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockDescribe(mock); + // Fields not given on the command line keep their current value, as the API replaces the agent + mock.when(request().withMethod("PUT").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json(""" + { + "name": "new-name", + "description": "Debug failed runs", + "agentInstructions": "New instructions", + "agentInstructionsTemplateId": "debug-template" + } + """)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("\"fix-failed-runs\"", "\"new-name\"") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "update", "-w", WSP_ID, "-i", "agt_1", "--new-name", "new-name", "--instructions", "New instructions"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "new-name", "updated")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDelete(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("DELETE").withPath("/agents/agt_1").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(204)); + + ExecOut out = exec(format, mock, "agents", "delete", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentDeleted(WSP_REF, "fix-failed-runs")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testEnable(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/enable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "enable", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "enabled")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDisable(OutputType format, MockServerClient mock) { + mockFindByName(mock); + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/agt_1/disable").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("active", "inactive") + "}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "disable", "-w", WSP_ID, "-n", "fix-failed-runs"); + + assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "disabled")); + } +} From fe392a21036ef72f31f66b0f252923a683c7abab Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:44 +0200 Subject: [PATCH 5/9] feat(agents): add agents launch Platform API 1.190.0 added agent launch, but tw had no command for it. tw agents launch starts a background run from a configured agent (--id or --name), from ad hoc instructions, or both. Launching a configured agent sends its stored instructions, like the web UI does. The command reads LaunchAgentResponse.agentRunId, which API 1.200.0 introduced: 1.190 servers send agentId instead, so the run id would print as null against them. That is why launch ships with 1.200 rather than with the rest of the agents command. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/AgentsCmd.java | 2 + .../tower/cli/commands/agents/LaunchCmd.java | 68 +++++++++++++++++++ .../cli/responses/agents/AgentLaunched.java | 38 +++++++++++ .../tower/cli/agents/AgentsCmdTest.java | 35 ++++++++++ 4 files changed, 143 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java diff --git a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java index 67e8f3fb..6c602c04 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java @@ -21,6 +21,7 @@ import io.seqera.tower.cli.commands.agents.DeleteCmd; import io.seqera.tower.cli.commands.agents.DisableCmd; import io.seqera.tower.cli.commands.agents.EnableCmd; +import io.seqera.tower.cli.commands.agents.LaunchCmd; import io.seqera.tower.cli.commands.agents.ListCmd; import io.seqera.tower.cli.commands.agents.UpdateCmd; import io.seqera.tower.cli.commands.agents.ViewCmd; @@ -37,6 +38,7 @@ DeleteCmd.class, EnableCmd.class, DisableCmd.class, + LaunchCmd.class, } ) public class AgentsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java new file mode 100644 index 00000000..6d29bac8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/LaunchCmd.java @@ -0,0 +1,68 @@ +/* + * 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.agents; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentLaunched; +import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.LaunchAgentRequest; +import io.seqera.tower.model.LaunchAgentResponse; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +import java.io.IOException; + +@Command( + name = "launch", + description = "Launch a background agent run. Specify a configured agent (--id or --name), instructions, or both. Given instructions override the agent's own; without an agent, the run acts as the current user." +) +public class LaunchCmd extends AbstractAgentsCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @ArgGroup + public AgentRefOptions.AgentRef agentRef; + + @ArgGroup + public AgentInstructionsOptions instructions; + + @Override + protected Response exec() throws ApiException, IOException { + Long wspId = workspaceId(workspace.workspace); + LaunchAgentRequest request = new LaunchAgentRequest(); + + if (agentRef != null) { + AgentDbDto agent = fetchAgent(agentRef, wspId); + request.agentConfigId(agent.getId()).instructions(agent.getAgentInstructions()); + } + if (instructions != null) { + request.instructions(instructions.read()); + } + if (request.getInstructions() == null) { + throw new TowerException("Specify an agent to launch (--id or --name) or the instructions to run (--instructions or --instructions-file)"); + } + + LaunchAgentResponse response = agentsApi().launchAgent(request, wspId); + return new AgentLaunched(workspaceRef(wspId), response.getAgentRunId(), response.getStatus()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java new file mode 100644 index 00000000..f28eb6bb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentLaunched.java @@ -0,0 +1,38 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; + +public class AgentLaunched extends Response { + + public final String workspaceRef; + public final String agentRunId; + public final String status; + + public AgentLaunched(String workspaceRef, String agentRunId, String status) { + this.workspaceRef = workspaceRef; + this.agentRunId = agentRunId; + this.status = status; + } + + @Override + public String toString() { + return ansi(String.format("%n @|yellow Agent run '%s' submitted at %s workspace (status: %s)|@%n", agentRunId, workspaceRef, status)); + } +} diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java index 2d91a3ae..774f884d 100644 --- a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java @@ -23,6 +23,7 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.agents.AgentAdded; import io.seqera.tower.cli.responses.agents.AgentDeleted; +import io.seqera.tower.cli.responses.agents.AgentLaunched; import io.seqera.tower.cli.responses.agents.AgentUpdated; import io.seqera.tower.cli.responses.agents.AgentView; import io.seqera.tower.cli.responses.agents.AgentsList; @@ -33,6 +34,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockserver.client.MockServerClient; +import org.mockserver.matchers.MatchType; import org.mockserver.model.MediaType; import java.io.IOException; @@ -213,4 +215,37 @@ void testDisable(OutputType format, MockServerClient mock) { assertOutput(format, out, new AgentUpdated(WSP_REF, "fix-failed-runs", "disabled")); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testLaunchConfiguredAgent(OutputType format, MockServerClient mock) { + mockDescribe(mock); + mock.when(request().withMethod("POST").withPath("/agents/launch").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json("{\"agentConfigId\":\"agt_1\",\"instructions\":\"Find the root cause\"}")), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRunId\":\"run_1\",\"status\":\"pending\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "launch", "-w", WSP_ID, "-i", "agt_1"); + + assertOutput(format, out, new AgentLaunched(WSP_REF, "run_1", "pending")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testLaunchAdHocInstructions(OutputType format, MockServerClient mock) { + mock.when(request().withMethod("POST").withPath("/agents/launch").withQueryStringParameter("workspaceId", WSP_ID) + .withBody(json("{\"instructions\":\"Summarize last week runs\"}", MatchType.STRICT)), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRunId\":\"run_2\",\"status\":\"pending\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "launch", "-w", WSP_ID, "--instructions", "Summarize last week runs"); + + assertOutput(format, out, new AgentLaunched(WSP_REF, "run_2", "pending")); + } + + @Test + void testLaunchWithoutAgentOrInstructions(MockServerClient mock) { + ExecOut out = exec(mock, "agents", "launch", "-w", WSP_ID); + + assertEquals(errorMessage(out.app, new TowerException("Specify an agent to launch (--id or --name) or the instructions to run (--instructions or --instructions-file)")), out.stdErr); + assertEquals(1, out.exitCode); + } } From cfc4151877a95ccf991a9cf6384823132d2e0515 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:45 +0200 Subject: [PATCH 6/9] feat(agents): add agents runs list and view Platform API 1.200.0 added agent run listing and run status, but tw had no command for them. tw agents runs list pages through a workspace's runs, newest first, with the API's keyword filter, and tw agents runs view shows a run's status, thread and session. The filter help lists only the keywords API 1.200 accepts; the service-account keywords came later. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/AgentsCmd.java | 2 + .../cli/commands/agents/runs/ListCmd.java | 57 +++++++++++++++ .../cli/commands/agents/runs/RunsCmd.java | 32 +++++++++ .../cli/commands/agents/runs/ViewCmd.java | 46 ++++++++++++ .../cli/responses/agents/AgentRunView.java | 49 +++++++++++++ .../cli/responses/agents/AgentRunsList.java | 71 +++++++++++++++++++ .../tower/cli/agents/AgentsCmdTest.java | 44 ++++++++++++ 7 files changed, 301 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java diff --git a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java index 6c602c04..6958244c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java @@ -25,6 +25,7 @@ import io.seqera.tower.cli.commands.agents.ListCmd; import io.seqera.tower.cli.commands.agents.UpdateCmd; import io.seqera.tower.cli.commands.agents.ViewCmd; +import io.seqera.tower.cli.commands.agents.runs.RunsCmd; import picocli.CommandLine.Command; @Command( @@ -39,6 +40,7 @@ EnableCmd.class, DisableCmd.class, LaunchCmd.class, + RunsCmd.class, } ) public class AgentsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java new file mode 100644 index 00000000..5e3eb671 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java @@ -0,0 +1,57 @@ +/* + * 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.agents.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentRunsList; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListAgentRunsResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "list", + description = "List agent runs, newest first" +) +public class ListCmd extends AbstractApiCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-f", "--filter"}, description = "Optional filter criteria, allowing the keywords: `status` (pending, running, completed, failed), `agentConfigId`, `workflowId` and `sourcePipelineId`. Free text is not supported. Example keyword usage: -f status:failed.") + public String filter; + + @Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListAgentRunsResponse response = agentsApi().listAgentRuns(wspId, filter, max, offset); + + return new AgentRunsList(workspaceRef(wspId), response.getAgentRuns(), PaginationInfo.from(paginationOptions, response.getTotalSize())); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java new file mode 100644 index 00000000..fd7d6228 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/RunsCmd.java @@ -0,0 +1,32 @@ +/* + * 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.agents.runs; + +import io.seqera.tower.cli.commands.AbstractRootCmd; +import picocli.CommandLine.Command; + +@Command( + name = "runs", + description = "Manage agent runs", + subcommands = { + ListCmd.class, + ViewCmd.class, + } +) +public class RunsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java new file mode 100644 index 00000000..0a3d2570 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ViewCmd.java @@ -0,0 +1,46 @@ +/* + * 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.agents.runs; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.global.WorkspaceRequiredOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.agents.AgentRunView; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View the status of an agent run" +) +public class ViewCmd extends AbstractApiCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Option(names = {"-i", "--id"}, description = "Agent run identifier", required = true) + public String id; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + return new AgentRunView(workspaceRef(wspId), agentsApi().getAgentRunStatus(id, wspId)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java new file mode 100644 index 00000000..ac9415ab --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunView.java @@ -0,0 +1,49 @@ +/* + * 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.responses.agents; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentRunStatusResponse; + +import java.io.PrintWriter; +import java.util.Objects; + +public class AgentRunView extends Response { + + public final String workspaceRef; + public final AgentRunStatusResponse agentRun; + + public AgentRunView(String workspaceRef, AgentRunStatusResponse agentRun) { + this.workspaceRef = workspaceRef; + this.agentRun = agentRun; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent run at %s workspace:|@%n", workspaceRef))); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", agentRun.getAgentRunId()); + table.addRow("Status", agentRun.getStatus()); + table.addRow("Thread ID", Objects.toString(agentRun.getThreadId(), "")); + table.addRow("Session ID", Objects.toString(agentRun.getSessionId(), "")); + table.print(); + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java new file mode 100644 index 00000000..0e4fcc1a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentRunsList.java @@ -0,0 +1,71 @@ +/* + * 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.responses.agents; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.AgentRunDbDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.FormatHelper.formatDate; +import static io.seqera.tower.cli.utils.FormatHelper.formatDescription; + +public class AgentRunsList extends Response { + + public final String workspaceRef; + public final List agentRuns; + + @JsonIgnore + @Nullable + private final PaginationInfo paginationInfo; + + public AgentRunsList(String workspaceRef, List agentRuns, @Nullable PaginationInfo paginationInfo) { + this.workspaceRef = workspaceRef; + this.agentRuns = agentRuns; + this.paginationInfo = paginationInfo; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Agent runs at %s workspace:|@%n", workspaceRef))); + if (agentRuns == null || agentRuns.isEmpty()) { + out.println(ansi(" @|yellow No agent runs found|@")); + return; + } + + TableList table = new TableList(out, 6, "ID", "Title", "Status", "Trigger", "Run ID", "Created"); + table.setPrefix(" "); + agentRuns.forEach(run -> table.addRow( + run.getId(), + formatDescription(run.getTitle(), 60), + run.getStatus() == null ? "NA" : run.getStatus().getValue(), + run.getTrigger() == null ? "" : Objects.toString(run.getTrigger().getType(), ""), + Objects.toString(run.getWorkflowId(), ""), + formatDate(run.getDateCreated()) + )); + table.print(); + PaginationInfo.addFooter(out, paginationInfo); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java index 774f884d..7307bf62 100644 --- a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java @@ -20,15 +20,20 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.seqera.tower.cli.BaseCmdTest; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.commands.global.PaginationOptions; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.agents.AgentAdded; import io.seqera.tower.cli.responses.agents.AgentDeleted; import io.seqera.tower.cli.responses.agents.AgentLaunched; +import io.seqera.tower.cli.responses.agents.AgentRunView; +import io.seqera.tower.cli.responses.agents.AgentRunsList; import io.seqera.tower.cli.responses.agents.AgentUpdated; import io.seqera.tower.cli.responses.agents.AgentView; import io.seqera.tower.cli.responses.agents.AgentsList; import io.seqera.tower.cli.utils.PaginationInfo; import io.seqera.tower.model.AgentDbDto; +import io.seqera.tower.model.AgentRunDbDto; +import io.seqera.tower.model.AgentRunStatusResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -248,4 +253,43 @@ void testLaunchWithoutAgentOrInstructions(MockServerClient mock) { assertEquals(errorMessage(out.app, new TowerException("Specify an agent to launch (--id or --name) or the instructions to run (--instructions or --instructions-file)")), out.stdErr); assertEquals(1, out.exitCode); } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRunsList(OutputType format, MockServerClient mock) throws JsonProcessingException { + String run = """ + { + "id": "run_1", + "title": "Debug run 4abc", + "status": "completed", + "trigger": {"type": "manual", "entityId": "agt_1"}, + "workflowId": "4abc", + "workspaceId": 75887156211589, + "dateCreated": "2026-09-03T10:00:00Z", + "lastUpdated": "2026-09-03T10:05:00Z" + } + """; + mock.when(request().withMethod("GET").withPath("/agents/runs") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("search", "status:completed") + .withQueryStringParameter("max", "100") + .withQueryStringParameter("offset", "0"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"agentRuns\":[" + run + "],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "runs", "list", "-w", WSP_ID, "-f", "status:completed"); + + assertOutput(format, out, new AgentRunsList(WSP_REF, List.of(parseJson(run, AgentRunDbDto.class)), PaginationInfo.from(new PaginationOptions(), 1L))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testRunsView(OutputType format, MockServerClient mock) throws JsonProcessingException { + String status = "{\"agentRunId\":\"run_1\",\"status\":\"running\",\"threadId\":\"thr_1\",\"sessionId\":\"ses_1\"}"; + mock.when(request().withMethod("GET").withPath("/agents/runs/run_1/status").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(status).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "agents", "runs", "view", "-w", WSP_ID, "-i", "run_1"); + + assertOutput(format, out, new AgentRunView(WSP_REF, parseJson(status, AgentRunStatusResponse.class))); + } } From 9ac9cef8501227bb98f1b35d2213dd9b5c9085a6 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:45 +0200 Subject: [PATCH 7/9] feat(agents): set an agent's service account and GitHub App credentials API 1.230.0 lets an agent run as an organization service account and use GitHub App credentials to clone, commit and push, but tw could not set either. - 'agents add|update --service-account-id --github-app-credentials-id' send serviceAccountId and githubAppCredentialId; update keeps the stored values when the options are omitted. - 'agents view' shows the service account, the GitHub App credentials and who created the agent (createdByUserName, API 1.211.0). - 'agents runs list --filter' documents the serviceAccountId and serviceAccountName keywords. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/agents/AddCmd.java | 10 +++++++++- .../tower/cli/commands/agents/UpdateCmd.java | 10 +++++++++- .../tower/cli/commands/agents/runs/ListCmd.java | 2 +- .../tower/cli/responses/agents/AgentView.java | 3 +++ .../io/seqera/tower/cli/agents/AgentsCmdTest.java | 14 +++++++++++--- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java index b96d4450..2e4977a9 100644 --- a/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/agents/AddCmd.java @@ -48,13 +48,21 @@ public class AddCmd extends AbstractAgentsCmd { @ArgGroup(multiplicity = "1") public AgentInstructionsOptions instructions; + @Option(names = {"--service-account-id"}, description = "Service account user ID. The agent runs with the permissions of this service account in the workspace.") + public Long serviceAccountId; + + @Option(names = {"--github-app-credentials-id"}, description = "GitHub App credentials identifier. Lets the agent clone, commit, and push using these credentials.") + public String githubAppCredentialsId; + @Override protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); CreateAgentRequest request = new CreateAgentRequest() .name(name) .description(description) - .agentInstructions(instructions.read()); + .agentInstructions(instructions.read()) + .serviceAccountId(serviceAccountId) + .githubAppCredentialId(githubAppCredentialsId); AgentDbDto agent = agentsApi().createAgent(request, wspId).getAgent(); return new AgentAdded(workspaceRef(wspId), agent); diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java index ad46ec7f..28584ec2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/agents/UpdateCmd.java @@ -53,6 +53,12 @@ public class UpdateCmd extends AbstractAgentsCmd { @ArgGroup public AgentInstructionsOptions instructions; + @Option(names = {"--service-account-id"}, description = "Service account user ID. The agent runs with the permissions of this service account in the workspace.") + public Long serviceAccountId; + + @Option(names = {"--github-app-credentials-id"}, description = "GitHub App credentials identifier. Lets the agent clone, commit, and push using these credentials.") + public String githubAppCredentialsId; + @Override protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); @@ -63,7 +69,9 @@ protected Response exec() throws ApiException, IOException { .name(coalesce(newName, agent.getName())) .description(coalesce(description, agent.getDescription())) .agentInstructions(instructions != null ? instructions.read() : agent.getAgentInstructions()) - .agentInstructionsTemplateId(agent.getAgentInstructionsTemplateId()); + .agentInstructionsTemplateId(agent.getAgentInstructionsTemplateId()) + .serviceAccountId(coalesce(serviceAccountId, agent.getServiceAccountId())) + .githubAppCredentialId(coalesce(githubAppCredentialsId, agent.getGithubAppCredentialId())); AgentDbDto updated = agentsApi().updateAgent(agent.getId(), request, wspId).getAgent(); return new AgentUpdated(workspaceRef(wspId), updated.getName(), "updated"); diff --git a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java index 5e3eb671..ddeb1c40 100644 --- a/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/agents/runs/ListCmd.java @@ -38,7 +38,7 @@ public class ListCmd extends AbstractApiCmd { @Mixin public WorkspaceRequiredOptions workspace; - @Option(names = {"-f", "--filter"}, description = "Optional filter criteria, allowing the keywords: `status` (pending, running, completed, failed), `agentConfigId`, `workflowId` and `sourcePipelineId`. Free text is not supported. Example keyword usage: -f status:failed.") + @Option(names = {"-f", "--filter"}, description = "Optional filter criteria, allowing the keywords: `status` (pending, running, completed, failed), `agentConfigId`, `workflowId`, `sourcePipelineId`, `serviceAccountId` and `serviceAccountName`. Free text is not supported. Example keyword usage: -f status:failed.") public String filter; @Mixin diff --git a/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java index 48c85352..6ed371f3 100644 --- a/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java +++ b/src/main/java/io/seqera/tower/cli/responses/agents/AgentView.java @@ -45,6 +45,9 @@ public void toString(PrintWriter out) { table.addRow("Name", agent.getName()); table.addRow("Description", Objects.toString(agent.getDescription(), "")); table.addRow("Status", agent.getStatus() == null ? "NA" : agent.getStatus().getValue()); + table.addRow("Service account", Objects.toString(agent.getServiceAccountName(), "")); + table.addRow("GitHub App credentials", Objects.toString(agent.getGithubAppCredentialId(), "")); + table.addRow("Created by", Objects.toString(agent.getCreatedByUserName(), "")); table.addRow("Created", formatDate(agent.getDateCreated())); table.addRow("Updated", formatDate(agent.getLastUpdated())); table.print(); diff --git a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java index 7307bf62..725be386 100644 --- a/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/agents/AgentsCmdTest.java @@ -64,9 +64,13 @@ class AgentsCmdTest extends BaseCmdTest { "description": "Debug failed runs", "agentInstructions": "Find the root cause", "agentInstructionsTemplateId": "debug-template", + "serviceAccountId": 42, + "serviceAccountName": "sa-agent", + "githubAppCredentialId": "cred1", "status": "active", "workspaceId": 75887156211589, "createdBy": 1264, + "createdByUserName": "jordi", "dateCreated": "2026-09-01T10:00:00Z", "lastUpdated": "2026-09-02T10:00:00Z" } @@ -144,14 +148,16 @@ void testAdd(OutputType format, MockServerClient mock) throws IOException { { "name": "fix-failed-runs", "description": "Debug failed runs", - "agentInstructions": "Find the root cause" + "agentInstructions": "Find the root cause", + "serviceAccountId": 42, + "githubAppCredentialId": "cred1" } """)), exactly(1)) .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT + "}").withContentType(MediaType.APPLICATION_JSON)); String instructions = tempFile("Find the root cause", "instructions", ".md"); ExecOut out = exec(format, mock, "agents", "add", "-w", WSP_ID, "-n", "fix-failed-runs", "-d", "Debug failed runs", - "--instructions-file", instructions); + "--instructions-file", instructions, "--service-account-id", "42", "--github-app-credentials-id", "cred1"); assertOutput(format, out, new AgentAdded(WSP_REF, agent())); } @@ -174,7 +180,9 @@ void testUpdate(OutputType format, MockServerClient mock) throws JsonProcessingE "name": "new-name", "description": "Debug failed runs", "agentInstructions": "New instructions", - "agentInstructionsTemplateId": "debug-template" + "agentInstructionsTemplateId": "debug-template", + "serviceAccountId": 42, + "githubAppCredentialId": "cred1" } """)), exactly(1)) .respond(response().withStatusCode(200).withBody("{\"agent\":" + AGENT.replace("\"fix-failed-runs\"", "\"new-name\"") + "}").withContentType(MediaType.APPLICATION_JSON)); From 65b07655aae1f3d497bb36dbd9718feb1a3809bd Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 15:48:30 +0200 Subject: [PATCH 8/9] docs: document the agents and service accounts commands An Agents section (list, view, add, update, delete, enable, disable, launch and runs list|view) and a Service accounts section, with examples. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/USAGE.md b/USAGE.md index fd2b9673..ebf6f523 100644 --- a/USAGE.md +++ b/USAGE.md @@ -709,6 +709,26 @@ $ tw data-links browse -w seqeralabs/showcase -i v1-user-6d8f44c239e2a098b3e02e9 FOLDER | technical/ | 0 ``` +## Agents + +Run `tw agents -h` to view the supported operations: `list`, `view`, `add`, `update`, `delete`, `enable`, `disable`, `launch`, and `runs list|view`. Agents must be enabled for the organization. + +```bash +tw agents add -w my-org/my-workspace -n triage --instructions-file triage.md +tw agents launch -w my-org/my-workspace -n triage +tw agents runs list -w my-org/my-workspace +``` + +## Service accounts + +Run `tw service-accounts -h` to view the supported operations: `list`, `add`, `view`, `update`, and `delete`. Service accounts are non-human organization members. Add one to a workspace with `tw participants add -t MEMBER -n `, or set one as an agent's identity with `tw agents add|update --service-account-id`. + +```bash +tw service-accounts add -o my-org -n ci-bot -d "CI pipeline launcher" +tw service-accounts list -o my-org +tw service-accounts list -w my-org/my-workspace +``` + [compute-envs]: https://docs.seqera.io/platform/latest/compute-envs/overview [credentials]: https://docs.seqera.io/platform/latest/credentials/overview [git-integration]: https://docs.seqera.io/platform/latest/git/overview From 8e8d05e108562731a648ea5f854839da1f2be2eb Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 15:48:32 +0200 Subject: [PATCH 9/9] build(native): register reflection metadata for the agents and service accounts commands Entries for the agents and service-accounts commands and responses, plus classes master already lacked, taken from a full test run under the GraalVM tracing agent. Response classes register allDeclaredFields, since Jackson serializes every field for -o json. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 399 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 2b031cd6..cd3e429b 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -160,6 +160,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.AgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.CollaboratorsCmd", "allDeclaredFields":true, @@ -250,6 +256,12 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.ServiceAccountsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.StudiosCmd", "allDeclaredFields":true, @@ -338,6 +350,108 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.agents.AbstractAgentsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"name"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentInstructionsOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"file"}, {"name":"text"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"agent"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.AgentRefOptions$AgentRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.DisableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.EnableCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.LaunchCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"agentRef"}, {"name":"instructions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"paginationOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"githubAppCredentialsId"}, {"name":"instructions"}, {"name":"newName"}, {"name":"ref"}, {"name":"serviceAccountId"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"ref"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filter"}, {"name":"paginationOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.RunsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.agents.runs.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.collaborators.AbstractCollaboratorsCmd", "allDeclaredFields":true, @@ -408,6 +522,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"computeEnvRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.ViewCmd", "allDeclaredFields":true, @@ -672,6 +793,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.AzCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.EksPlatform", "allDeclaredFields":true, @@ -708,6 +836,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.computeenvs.platforms.GoogleCloudPlatform$SchedOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"billingExportTable"}, {"name":"machineTypes"}, {"name":"maxCpusPerUser"}, {"name":"maxSpotAttempts"}, {"name":"nvmeEnabled"}, {"name":"predictionModel"}, {"name":"provisioningModel"}, {"name":"schedEnabled"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.computeenvs.platforms.K8sPlatform", "allDeclaredFields":true, @@ -832,6 +967,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.credentials.ValidateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"credentialsRefOptions"}, {"name":"force"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.credentials.add.AbstractAddCmd", "allDeclaredFields":true, @@ -1546,6 +1688,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.NullableLaunchOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"fusionVersion"}, {"name":"nextflowVersion"}, {"name":"outputDir"}, {"name":"syntaxParser"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelines.PipelineRefOptions", "allDeclaredFields":true, @@ -1764,6 +1913,67 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.AbstractServiceAccountsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.AddCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"name"}, {"name":"organizationRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.DeleteCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ListCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"paginationOptions"}, {"name":"scope"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ListCmd$Scope", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"workspaceRef"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"serviceAccount"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ServiceAccountRefOptions$ServiceAccountRef", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"id"}, {"name":"name"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.UpdateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"description"}, {"name":"newName"}, {"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.serviceaccounts.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"organizationRef"}, {"name":"ref"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.AbstractStudiosCmd", "allDeclaredFields":true, @@ -1865,6 +2075,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StudioRemoteConfigOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"repository"}, {"name":"revision"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StudioTemplateOptions", "allDeclaredFields":true, @@ -2048,6 +2265,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.CredentialsValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.InfoResponse", "allDeclaredFields":true, @@ -2089,6 +2314,54 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentLaunched", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentRunView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentRunsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.agents.AgentsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.collaborators.CollaboratorsList", "allDeclaredFields":true, @@ -2125,6 +2398,38 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvValidated", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimaryGet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.computeenvs.ComputeEnvsPrimarySet", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.data.DataLinkContentList", "allDeclaredFields":true, @@ -2178,6 +2483,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.datasets.DatasetDownload", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.datasets.DatasetList", "allDeclaredFields":true, @@ -2274,6 +2587,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsAdded", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.organizations.OrganizationsDeleted", "allDeclaredFields":true, @@ -2292,6 +2613,14 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.organizations.OrganizationsView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.participants.ParticipantAdded", "allDeclaredFields":true, @@ -2328,6 +2657,14 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesDeleted", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesExport", "allDeclaredFields":true, @@ -2406,12 +2743,44 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.RunView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.RunViewMetrics", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.runs.tasks.TaskView", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.tasks.TasksView", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.runs.utils.MetricFormatMapper", + "allDeclaredFields":true, + "allDeclaredMethods":true, + "allDeclaredConstructors":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.secrets.SecretAdded", "allDeclaredFields":true, @@ -2442,6 +2811,36 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountAdded", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountDeleted", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.serviceaccounts.ServiceAccountsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioCheckpointsList", "allDeclaredFields":true,