diff --git a/USAGE.md b/USAGE.md index 1685ee02..760b411f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -758,10 +758,21 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s ## Agents -Run `tw agents -h` to view the supported operations: `list`, `view`, `add`, `update`, `delete`, `enable`, and `disable`. Agents must be enabled for the organization. +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 +``` + +## Lineage + +Run `tw lineage -h` to view the supported operations: `resolve`, `view`, `search`, `upstream`, and `downstream`. Lineage must be enabled for the workspace. + +```bash +tw lineage resolve -w my-org/my-workspace --file-path s3://bucket/results/out.bam +tw lineage upstream -w my-org/my-workspace -i ``` ## Audit logs @@ -785,7 +796,7 @@ tw audit-logs export --after 2026-09-01T00:00:00Z --before 2026-09-30T23:59:59Z - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. - `tw actions add cron|bucket`. -- `tw studios star|unstar|extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. +- `tw studios star|unstar|extend|logs|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. - `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. diff --git a/conf/reflect-config.json b/conf/reflect-config.json index a253af03..7ecd41db 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -226,6 +226,12 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.LineageCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.ManagedIdentitiesCmd", "queryAllDeclaredMethods":true, @@ -469,6 +475,13 @@ "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, @@ -490,6 +503,26 @@ "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.auditlogs.AuditLogsFilterOptions", "queryAllDeclaredMethods":true, @@ -1638,6 +1671,71 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.lineage.AbstractLineageCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.AbstractNavigateCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lidOptions"}, {"name":"type"}, {"name":"workspace"}] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.DownstreamCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.LidOptions", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lid"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"source"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd$Run", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"runName"}, {"name":"sessionId"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ResolveCmd$Source", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"filePath"}, {"name":"run"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.SearchCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"max"}, {"name":"pageToken"}, {"name":"query"}], + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.UpstreamCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.lineage.ViewCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"lidOptions"}, {"name":"summary"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.managedidentities.AbstractManagedIdentitiesCmd", "queryAllDeclaredMethods":true, @@ -2374,6 +2472,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.LogsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"maxLength"}, {"name":"next"}, {"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.ParentStudioRefOptions", "allDeclaredFields":true, @@ -2773,6 +2878,24 @@ "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, @@ -3034,6 +3157,24 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageRecordView", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageRecordsList", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.lineage.LineageResolved", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.managedidentities.ManagedCredentialsAdded", "allDeclaredFields":true, @@ -3508,6 +3649,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioLog", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStarred", "allDeclaredFields":true, diff --git a/src/main/java/io/seqera/tower/cli/Tower.java b/src/main/java/io/seqera/tower/cli/Tower.java index b0e4ca33..aabebfbe 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -33,6 +33,7 @@ import io.seqera.tower.cli.commands.DatasetsCmd; import io.seqera.tower.cli.commands.InfoCmd; import io.seqera.tower.cli.commands.LaunchCmd; +import io.seqera.tower.cli.commands.LineageCmd; import io.seqera.tower.cli.commands.ManagedIdentitiesCmd; import io.seqera.tower.cli.commands.MembersCmd; import io.seqera.tower.cli.commands.OrganizationsCmd; @@ -76,6 +77,7 @@ InfoCmd.class, LabelsCmd.class, LaunchCmd.class, + LineageCmd.class, ManagedIdentitiesCmd.class, MembersCmd.class, OrganizationsCmd.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 d013e389..e9dc1c78 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -30,6 +30,7 @@ import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; +import io.seqera.tower.api.LineageApi; import io.seqera.tower.api.NextflowApi; import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; @@ -255,6 +256,10 @@ protected LaunchApi launchApi() throws ApiException { return launchApi == null ? new LaunchApi(apiClient()) : launchApi; } + protected LineageApi lineageApi() throws ApiException { + return new LineageApi(apiClient()); + } + protected NextflowApi nextflowApi() throws ApiException { return nextflowApi == null ? new NextflowApi(apiClient()) : nextflowApi; } 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..6958244c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AgentsCmd.java @@ -21,9 +21,11 @@ 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; +import io.seqera.tower.cli.commands.agents.runs.RunsCmd; import picocli.CommandLine.Command; @Command( @@ -37,6 +39,8 @@ DeleteCmd.class, EnableCmd.class, DisableCmd.class, + LaunchCmd.class, + RunsCmd.class, } ) public class AgentsCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java b/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java new file mode 100644 index 00000000..b405922b --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/LineageCmd.java @@ -0,0 +1,39 @@ +/* + * 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.lineage.DownstreamCmd; +import io.seqera.tower.cli.commands.lineage.ResolveCmd; +import io.seqera.tower.cli.commands.lineage.SearchCmd; +import io.seqera.tower.cli.commands.lineage.UpstreamCmd; +import io.seqera.tower.cli.commands.lineage.ViewCmd; +import picocli.CommandLine.Command; + +@Command( + name = "lineage", + description = "Explore data lineage records. Lineage must be enabled for the workspace.", + subcommands = { + ResolveCmd.class, + ViewCmd.class, + SearchCmd.class, + UpstreamCmd.class, + DownstreamCmd.class, + } +) +public class LineageCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index 8cbb28b0..ba1e9fce 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -20,6 +20,7 @@ import io.seqera.tower.cli.commands.studios.CheckpointsCmd; import io.seqera.tower.cli.commands.studios.DeleteCmd; import io.seqera.tower.cli.commands.studios.ExtendCmd; +import io.seqera.tower.cli.commands.studios.LogsCmd; import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; import io.seqera.tower.cli.commands.studios.StarCmd; import io.seqera.tower.cli.commands.studios.UnstarCmd; @@ -50,6 +51,7 @@ StarCmd.class, UnstarCmd.class, ExtendCmd.class, + LogsCmd.class, } ) public class StudiosCmd 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/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/commands/computeenvs/platforms/AwsCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java index 1e937a3e..59ec6c5c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AwsCloudPlatform.java @@ -76,6 +76,8 @@ public AwsCloudConfig computeConfig() throws ApiException, IOException { .machineTypes(sched.machineTypes) .predictionModel(sched.predictionModel) .nvmeEnabled(sched.nvmeEnabled) + .maxCpusPerUser(sched.maxCpusPerUser) + .maxSpotAttempts(sched.maxSpotAttempts) .backendStrategy(sched.backendStrategy) .pool(sched.warmPool()); config.schedConfig(schedConfig); @@ -134,6 +136,12 @@ public static class SchedOptions { @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to EC2 instance types that provide local SSD (NVMe) storage for faster I/O.") public Boolean nvmeEnabled; + @Option(names = {"--max-cpus-per-user"}, description = "Maximum concurrent vCPUs a single user may use across their runs on this compute environment. Must be a positive integer. If absent, there is no limit.") + public Integer maxCpusPerUser; + + @Option(names = {"--max-spot-attempts"}, description = "Total spot attempts for a task, including the first, before giving up on spot capacity (1-10). With SPOT_FIRST, the task then falls back to on-demand. Only valid with the SPOT and SPOT_FIRST provisioning models.") + public Integer maxSpotAttempts; + @Option(names = {"--backend-strategy"}, description = "Backend the Seqera scheduler uses to run tasks. ECS delegates task execution to AWS ECS; EC2 runs tasks directly on EC2 instances. Valid values: ECS, EC2.") public SchedConfig.BackendStrategyEnum backendStrategy; diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java index dd4f600a..f72f56d8 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/AzCloudPlatform.java @@ -73,7 +73,9 @@ public AzCloudConfig computeConfig() throws ApiException, IOException { .provisioningModel(sched.provisioningModel) .machineTypes(sched.machineTypes) .predictionModel(sched.predictionModel) - .nvmeEnabled(sched.nvmeEnabled); + .nvmeEnabled(sched.nvmeEnabled) + .maxCpusPerUser(sched.maxCpusPerUser) + .maxSpotAttempts(sched.maxSpotAttempts); config.schedConfig(schedConfig); } @@ -117,6 +119,12 @@ public static class SchedOptions { @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to Azure VM sizes that provide local SSD storage for faster I/O.") public Boolean nvmeEnabled; + + @Option(names = {"--max-cpus-per-user"}, description = "Maximum concurrent vCPUs a single user may use across their runs on this compute environment. Must be a positive integer. If absent, there is no limit.") + public Integer maxCpusPerUser; + + @Option(names = {"--max-spot-attempts"}, description = "Total spot attempts for a task, including the first, before giving up on spot capacity (1-10). With SPOT_FIRST, the task then falls back to on-demand. Only valid with the SPOT and SPOT_FIRST provisioning models.") + public Integer maxSpotAttempts; } public static class AdvancedOptions { diff --git a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java index fa832155..5ed0b70e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java +++ b/src/main/java/io/seqera/tower/cli/commands/computeenvs/platforms/GoogleCloudPlatform.java @@ -79,7 +79,9 @@ public GoogleCloudConfig computeConfig() throws ApiException, IOException { .provisioningModel(sched.provisioningModel) .machineTypes(sched.machineTypes) .predictionModel(sched.predictionModel) - .nvmeEnabled(sched.nvmeEnabled); + .nvmeEnabled(sched.nvmeEnabled) + .maxCpusPerUser(sched.maxCpusPerUser) + .maxSpotAttempts(sched.maxSpotAttempts); config.schedConfig(schedConfig); } @@ -152,6 +154,12 @@ public static class SchedOptions { @Option(names = {"--nvme-storage"}, description = "Restrict the Seqera scheduler to Compute Engine machine types that provide local SSD (NVMe) storage for faster I/O.") public Boolean nvmeEnabled; + + @Option(names = {"--max-cpus-per-user"}, description = "Maximum concurrent vCPUs a single user may use across their runs on this compute environment. Must be a positive integer. If absent, there is no limit.") + public Integer maxCpusPerUser; + + @Option(names = {"--max-spot-attempts"}, description = "Total spot attempts for a task, including the first, before giving up on spot capacity (1-10). With SPOT_FIRST, the task then falls back to on-demand. Only valid with the SPOT and SPOT_FIRST provisioning models.") + public Integer maxSpotAttempts; } public static class AdvancedOptions { diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java new file mode 100644 index 00000000..a9669b88 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.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.lineage; + +import io.seqera.tower.cli.commands.AbstractApiCmd; +import picocli.CommandLine.Command; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Command +public abstract class AbstractLineageCmd extends AbstractApiCmd { + + /** + * The SDK encodes the LID path segment once, and the API decodes it twice: proxies normalize an + * encoded '/' or ':' in paths, so the web UI double-encodes LIDs. Encode once more here to match. + */ + protected static String lidPathSegment(String lid) { + return URLEncoder.encode(lid, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java new file mode 100644 index 00000000..49fd5ef1 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java @@ -0,0 +1,53 @@ +/* + * 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.lineage; + +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.lineage.LineageRecordsList; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command +public abstract class AbstractNavigateCmd extends AbstractLineageCmd { + + public enum RecordType {FileOutput, TaskRun} + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + public LidOptions lidOptions; + + @Option(names = {"-t", "--type"}, description = "Show only records of this type (${COMPLETION-CANDIDATES})") + public RecordType type; + + protected abstract String direction(); + + protected abstract SearchResult navigate(String lid, Long wspId, String type) throws ApiException; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + SearchResult result = navigate(lidPathSegment(lidOptions.lid), wspId, type == null ? null : type.name()); + return new LineageRecordsList(String.format("Lineage records %s of '%s'", direction(), lidOptions.lid), result.getRecords(), result.getNextPageToken()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java new file mode 100644 index 00000000..8b09df15 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java @@ -0,0 +1,39 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; + +@Command( + name = "downstream", + description = "List the records one hop downstream of a task or file record. By default, the tasks that consumed a file, or the output files of a task." +) +public class DownstreamCmd extends AbstractNavigateCmd { + + @Override + protected String direction() { + return "downstream"; + } + + @Override + protected SearchResult navigate(String lid, Long wspId, String type) throws ApiException { + return lineageApi().navigateLineageDownstream(lid, wspId, type); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java b/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java new file mode 100644 index 00000000..ffc9d025 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java @@ -0,0 +1,26 @@ +/* + * 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.lineage; + +import picocli.CommandLine.Option; + +public class LidOptions { + + @Option(names = {"-i", "--id"}, description = "Lineage record identifier (LID), e.g. lid:///", required = true) + public String lid; +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java new file mode 100644 index 00000000..ec7ae0eb --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java @@ -0,0 +1,64 @@ +/* + * 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.lineage; + +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.lineage.LineageResolved; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "resolve", + description = "Resolve a pipeline run or a file path to its lineage record identifier (LID)" +) +public class ResolveCmd extends AbstractLineageCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @ArgGroup(multiplicity = "1") + public Source source; + + public static class Source { + + @ArgGroup(exclusive = false) + public Run run; + + @Option(names = {"--file-path"}, description = "Absolute path of a file produced by a pipeline run") + public String filePath; + } + + public static class Run { + + @Option(names = {"--session-id"}, description = "Nextflow session ID of the run", required = true) + public String sessionId; + } + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = source.run != null ? source.run.sessionId : null; + + String lid = lineageApi().resolveLineage(wspId, sessionId, source.filePath).getLid(); + return new LineageResolved(workspaceRef(wspId), lid); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java new file mode 100644 index 00000000..f045bfc7 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.lineage.LineageRecordsList; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command( + name = "search", + description = "Search lineage records across all the workspaces you can access" +) +public class SearchCmd extends AbstractLineageCmd { + + @Option(names = {"-q", "--query"}, description = "Search query. Combines free text and the qualifiers: `type`, `workflow`, `task`, `label`, and `workspace` (org/name) or `workspaceId` to narrow the scope. Whitespace means AND, a comma inside a qualifier means OR. Example: -q 'type:FileOutput workspace:acme/dev multiqc'.") + public String query; + + @Option(names = {"--max"}, description = "Maximum number of records per page (capped by the server)") + public Integer max; + + @Option(names = {"--page-token"}, description = "Token of the page to show, as printed by a previous search with the same query") + public String pageToken; + + @Override + protected Response exec() throws ApiException { + SearchResult result = lineageApi().searchLineage(query, pageToken, max); + return new LineageRecordsList("Lineage records", result.getRecords(), result.getNextPageToken()); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java new file mode 100644 index 00000000..66cf239f --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java @@ -0,0 +1,39 @@ +/* + * 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.lineage; + +import io.seqera.tower.ApiException; +import io.seqera.tower.model.SearchResult; +import picocli.CommandLine.Command; + +@Command( + name = "upstream", + description = "List the records one hop upstream of a task or file record. By default, the task that produced a file, or the input files of a task." +) +public class UpstreamCmd extends AbstractNavigateCmd { + + @Override + protected String direction() { + return "upstream"; + } + + @Override + protected SearchResult navigate(String lid, Long wspId, String type) throws ApiException { + return lineageApi().navigateLineageUpstream(lid, wspId, type); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java new file mode 100644 index 00000000..f0fd9ea5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java @@ -0,0 +1,53 @@ +/* + * 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.lineage; + +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.lineage.LineageRecordView; +import io.seqera.tower.model.LineageRecordResponse; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command( + name = "view", + description = "View a lineage record" +) +public class ViewCmd extends AbstractLineageCmd { + + @Mixin + public WorkspaceRequiredOptions workspace; + + @Mixin + public LidOptions lidOptions; + + @Option(names = {"--summary"}, description = "Show only the record summary (name, path or run, and labels), read from the lineage index instead of the full record.") + public boolean summary; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String lid = lidPathSegment(lidOptions.lid); + LineageRecordResponse record = summary + ? lineageApi().getLineageSummary(lid, wspId) + : lineageApi().getLineageRecord(lid, wspId); + return new LineageRecordView(workspaceRef(wspId), record); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java new file mode 100644 index 00000000..bd18625e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java @@ -0,0 +1,53 @@ +/* + * 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.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioLog; +import io.seqera.tower.model.DataStudioLogResponse; +import picocli.CommandLine; + +@CommandLine.Command( + name = "logs", + description = "Display the process log of the latest studio session." +) +public class LogsCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @CommandLine.Option(names = {"--next"}, description = "Pagination cursor of the log page to display, as printed at the end of the previous page.") + public String next; + + @CommandLine.Option(names = {"--max-length"}, description = "Maximum number of characters to return.") + public Long maxLength; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + DataStudioLogResponse response = studiosApi().getDataStudioLog(sessionId, wspId, next, maxLength); + + return new StudioLog(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), response.getLog()); + } +} 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/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/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java new file mode 100644 index 00000000..f84e766a --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java @@ -0,0 +1,66 @@ +/* + * 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.lineage; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.seqera.tower.cli.exceptions.TowerRuntimeException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.DisplayData; +import io.seqera.tower.model.LineageRecordResponse; + +import java.io.PrintWriter; +import java.util.Objects; + +import static io.seqera.tower.cli.utils.JsonHelper.prettyJson; + +public class LineageRecordView extends Response { + + public final String workspaceRef; + public final LineageRecordResponse record; + + public LineageRecordView(String workspaceRef, LineageRecordResponse record) { + this.workspaceRef = workspaceRef; + this.record = record; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Lineage record at %s workspace:|@%n", workspaceRef))); + DisplayData display = record.getDisplayData() == null ? new DisplayData() : record.getDisplayData(); + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("LID", record.getLid()); + table.addRow("Type", record.getType()); + table.addRow("Record URI", Objects.toString(record.getRecordUri(), "")); + table.addRow("Run ID", Objects.toString(display.getWorkflowId(), "")); + table.addRow("Run name", Objects.toString(display.getRunName(), "")); + table.addRow("Process", Objects.toString(display.getProcessName(), "")); + table.print(); + + if (record.getData() != null) { + out.println(ansi(String.format("%n @|bold Data:|@%n"))); + try { + out.println(prettyJson(record.getData())); + } catch (JsonProcessingException e) { + throw new TowerRuntimeException("Unable to format the lineage record data: " + e.getMessage()); + } + } + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java new file mode 100644 index 00000000..9c9e6036 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java @@ -0,0 +1,67 @@ +/* + * 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.lineage; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.DisplayData; +import io.seqera.tower.model.LineageRecordResponse; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Objects; + +public class LineageRecordsList extends Response { + + public final String title; + public final List records; + public final String nextPageToken; + + public LineageRecordsList(String title, List records, String nextPageToken) { + this.title = title; + this.records = records; + this.nextPageToken = nextPageToken; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold %s:|@%n", title))); + if (records == null || records.isEmpty()) { + out.println(ansi(" @|yellow No lineage records found|@")); + return; + } + + TableList table = new TableList(out, 5, "LID", "Type", "Run name", "Process", "Workspace ID"); + table.setPrefix(" "); + records.forEach(record -> { + DisplayData display = record.getDisplayData() == null ? new DisplayData() : record.getDisplayData(); + table.addRow( + record.getLid(), + record.getType(), + Objects.toString(display.getRunName(), ""), + Objects.toString(display.getProcessName(), ""), + Objects.toString(display.getWorkspaceId(), "") + ); + }); + table.print(); + if (nextPageToken != null) { + out.println(String.format("%n More records available, use: --page-token %s", nextPageToken)); + } + out.println(""); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java new file mode 100644 index 00000000..f06ce91d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.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.lineage; + +import io.seqera.tower.cli.responses.Response; + +public class LineageResolved extends Response { + + public final String workspaceRef; + public final String lid; + + public LineageResolved(String workspaceRef, String lid) { + this.workspaceRef = workspaceRef; + this.lid = lid; + } + + @Override + public String toString() { + return ansi(String.format("%n @|bold Lineage record at %s workspace:|@ %s%n", workspaceRef, lid)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java new file mode 100644 index 00000000..51562896 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.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.responses.studios; + +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.LogPage; + +import java.io.PrintWriter; +import java.util.List; + +public class StudioLog extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final LogPage log; + + public StudioLog(String userSuppliedStudioIdentifier, String workspaceRef, LogPage log) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.log = log; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Log of studio %s at %s workspace:|@%n", userSuppliedStudioIdentifier, workspaceRef))); + + // SDK 1.200 types LogPage.entries as Object (the spec says Iterator), but Platform sends a JSON array + List entries = log != null && log.getEntries() instanceof List list ? list : List.of(); + if (entries.isEmpty()) { + if (log != null && Boolean.TRUE.equals(log.getPending())) { + out.println(ansi(" @|yellow Waiting for the output log|@")); + } else { + out.println(ansi(" @|yellow No log entries found|@")); + } + return; + } + + entries.forEach(out::println); + + if (Boolean.TRUE.equals(log.getTruncated())) { + out.println(ansi(String.format("%n @|yellow Log truncated|@"))); + } + if (log.getForwardToken() != null) { + out.println(ansi(String.format("%n @|bold Next page:|@ --next %s", log.getForwardToken()))); + } + 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 2d91a3ae..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,19 +20,26 @@ 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; 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 +220,76 @@ 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); + } + + @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))); + } } diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java index d352c640..0aac7f17 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AwsCloudPlatformTest.java @@ -869,6 +869,8 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "schedConfig": { "predictionModel": "qr/v3", "nvmeEnabled": true, + "maxCpusPerUser": 64, + "maxSpotAttempts": 3, "backendStrategy": "EC2", "pool": { "enabled": true, @@ -899,6 +901,10 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "--prediction-model", "qr/v3", "--nvme-storage", + "--max-cpus-per-user", + "64", + "--max-spot-attempts", + "3", "--backend-strategy", "EC2", "--warm-pool", diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java index 53316145..9459e39b 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/AzCloudPlatformTest.java @@ -279,7 +279,9 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "schedEnabled": true, "schedConfig": { "predictionModel": "qr/v3", - "nvmeEnabled": true + "nvmeEnabled": true, + "maxCpusPerUser": 64, + "maxSpotAttempts": 3 } }, "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" @@ -305,7 +307,11 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "--sched-enabled", "--prediction-model", "qr/v3", - "--nvme-storage" + "--nvme-storage", + "--max-cpus-per-user", + "64", + "--max-spot-attempts", + "3" ); // then diff --git a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java index 904d7542..5851b4c1 100644 --- a/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java +++ b/src/test/java/io/seqera/tower/cli/computeenvs/platforms/GoogleCloudPlatformTest.java @@ -418,7 +418,9 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "schedEnabled": true, "schedConfig": { "predictionModel": "qr/v3", - "nvmeEnabled": true + "nvmeEnabled": true, + "maxCpusPerUser": 64, + "maxSpotAttempts": 3 } }, "credentialsId": "6XfOhoztUq6de3Dw3X9LSb" @@ -444,7 +446,11 @@ void testAddWithIntelligentComputeOptions(MockServerClient mock) throws IOExcept "--sched-enabled", "--prediction-model", "qr/v3", - "--nvme-storage" + "--nvme-storage", + "--max-cpus-per-user", + "64", + "--max-spot-attempts", + "3" ); // then diff --git a/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java new file mode 100644 index 00000000..79e018dc --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java @@ -0,0 +1,188 @@ +/* + * 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.lineage; + +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.responses.lineage.LineageRecordView; +import io.seqera.tower.cli.responses.lineage.LineageRecordsList; +import io.seqera.tower.cli.responses.lineage.LineageResolved; +import io.seqera.tower.model.LineageRecordResponse; +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.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; + +class LineageCmdTest extends BaseCmdTest { + + private static final String WSP_ID = "75887156211589"; + private static final String WSP_REF = "[organization1 / workspace1]"; + private static final String LID = "lid://abc123/multiqc report.html"; + // The LID is sent double-encoded, as the web UI does: the API decodes it twice back to LID + private static final String LID_PATH = "lid%253A%252F%252Fabc123%252Fmultiqc%2Breport.html"; + + private static final String FILE_RECORD = """ + { + "lid": "lid://abc123/multiqc report.html", + "type": "FileOutput", + "recordUri": "s3://bucket/lineage/abc123/multiqc report.html.data.json", + "data": {"path": "s3://bucket/results/multiqc report.html", "labels": ["qc"]}, + "displayData": {"workspaceId": 75887156211589, "workflowId": "4abc", "runName": "happy_turing", "processName": "MULTIQC"} + } + """; + + private static final String TASK_RECORD = """ + { + "lid": "lid://def456", + "type": "TaskRun", + "displayData": {"workspaceId": 75887156211589, "workflowId": "4abc", "runName": "happy_turing", "processName": "MULTIQC"} + } + """; + + @BeforeEach + void init(MockServerClient mock) { + mock.reset(); + } + + private void mockWorkspace(MockServerClient mock) { + 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 assertSentRawPath(MockServerClient mock, String rawPath) { + String sent = mock.retrieveRecordedRequests(request().withMethod("GET").withPath("/lineage/.*"))[0].getPath().getValue(); + assertEquals(rawPath, sent); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testResolveRun(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/resolve") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("sessionId", "f1e2d3"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"lid\":\"lid://wf789\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3"); + + assertOutput(format, out, new LineageResolved(WSP_REF, "lid://wf789")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testResolveFilePath(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/resolve") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("filePath", "s3://bucket/results/report.html"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"lid\":\"lid://abc123/report.html\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "resolve", "-w", WSP_ID, "--file-path", "s3://bucket/results/report.html"); + + assertOutput(format, out, new LineageResolved(WSP_REF, "lid://abc123/report.html")); + } + + @Test + void testResolveRejectsRunAndFilePath(MockServerClient mock) { + ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--file-path", "/data/x"); + + assertEquals(2, out.exitCode); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testView(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(FILE_RECORD).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "view", "-w", WSP_ID, "-i", LID); + + assertSentRawPath(mock, "/lineage/" + LID_PATH); + assertOutput(format, out, new LineageRecordView(WSP_REF, parseJson(FILE_RECORD, LineageRecordResponse.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewSummary(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/summary").withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody(FILE_RECORD).withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "view", "-w", WSP_ID, "-i", LID, "--summary"); + + assertSentRawPath(mock, "/lineage/" + LID_PATH + "/summary"); + assertOutput(format, out, new LineageRecordView(WSP_REF, parseJson(FILE_RECORD, LineageRecordResponse.class))); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testSearch(OutputType format, MockServerClient mock) throws JsonProcessingException { + mock.when(request().withMethod("GET").withPath("/lineage/search") + .withQueryStringParameter("q", "type:FileOutput workspace:organization1/workspace1 multiqc") + .withQueryStringParameter("pageToken", "tok1") + .withQueryStringParameter("pageSize", "2"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + FILE_RECORD + "],\"nextPageToken\":\"tok2\"}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "search", "-q", "type:FileOutput workspace:organization1/workspace1 multiqc", "--page-token", "tok1", "--max", "2"); + + assertOutput(format, out, new LineageRecordsList("Lineage records", List.of(parseJson(FILE_RECORD, LineageRecordResponse.class)), "tok2")); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpstream(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/upstream") + .withQueryStringParameter("workspaceId", WSP_ID) + .withQueryStringParameter("type", "TaskRun"), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + TASK_RECORD + "]}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "upstream", "-w", WSP_ID, "-i", LID, "--type", "taskrun"); + + assertSentRawPath(mock, "/lineage/" + LID_PATH + "/upstream"); + assertOutput(format, out, new LineageRecordsList("Lineage records upstream of '" + LID + "'", List.of(parseJson(TASK_RECORD, LineageRecordResponse.class)), null)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testDownstream(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + mock.when(request().withMethod("GET").withPath("/lineage/.*/downstream") + .withQueryStringParameter("workspaceId", WSP_ID), exactly(1)) + .respond(response().withStatusCode(200).withBody("{\"records\":[" + FILE_RECORD + "]}").withContentType(MediaType.APPLICATION_JSON)); + + ExecOut out = exec(format, mock, "lineage", "downstream", "-w", WSP_ID, "-i", "lid://def456"); + + assertSentRawPath(mock, "/lineage/lid%253A%252F%252Fdef456/downstream"); + assertOutput(format, out, new LineageRecordsList("Lineage records downstream of 'lid://def456'", List.of(parseJson(FILE_RECORD, LineageRecordResponse.class)), null)); + } +} diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index f46b3556..7e0aa3e7 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -32,6 +32,7 @@ import io.seqera.tower.cli.responses.studios.StudioDeleted; import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.cli.responses.studios.StudioLog; import io.seqera.tower.cli.responses.studios.StudioStarred; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; @@ -42,6 +43,7 @@ import io.seqera.tower.model.DataStudioCheckpointDto; import io.seqera.tower.model.DataStudioDto; import io.seqera.tower.model.DataStudioTemplatesListResponse; +import io.seqera.tower.model.LogPage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -3142,4 +3144,34 @@ void testStartWithWaitShowsProgressWarnings(MockServerClient mock) { assertTrue(out.stdOut.contains("Warning: " + checkpointWarning), out.stdOut); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testLogs(OutputType format, MockServerClient mock) throws JsonProcessingException { + mockWorkspace(mock); + String logPage = """ + { + "entries": ["Starting studio", "Studio ready"], + "forwardToken": "f/123", + "rewindToken": "b/123", + "pending": false, + "truncated": false + } + """; + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7/log") + .withQueryStringParameter("workspaceId", "75887156211589") + .withQueryStringParameter("next", "f/100") + .withQueryStringParameter("maxLength", "500"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"log\": " + logPage + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "logs", "-w", "75887156211589", "-i", "3e8370e7", "--next", "f/100", "--max-length", "500"); + + assertOutput(format, out, new StudioLog("3e8370e7", "[organization1 / workspace1]", parseJson(logPage, LogPage.class))); + if (format == OutputType.console) { + assertTrue(out.stdOut.contains(String.format("Starting studio%nStudio ready")), out.stdOut); + assertTrue(out.stdOut.contains("--next f/123"), out.stdOut); + } + } }