From 35fb0cd2482467fefefb8d0e5fb1567e24881acc Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:12:21 +0200 Subject: [PATCH 1/7] 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 2b445520612449fcc29dff41eb15a13d589ddaa8 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:12:52 +0200 Subject: [PATCH 2/7] 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 168e202cd0908a0f476d58d1ccb92e0c2144d1e4 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:14:18 +0200 Subject: [PATCH 3/7] feat(lineage): add lineage command Platform API 1.200.0 added read-only lineage endpoints (resolve, search, record, summary, upstream, downstream) that tw did not expose. tw lineage resolve turns a run (session ID) or a file path into its LID; view shows a record, or its index summary with --summary; search runs the cross-workspace query DSL with token paging; upstream and downstream walk one data-flow hop, optionally filtered by record type. LIDs are double-encoded in the path, as the web UI does, because the API decodes them twice to survive proxy path normalization. API 1.200 resolves a run by session ID alone. The run name parameter, the pipeline search qualifiers and the record's pipeline name came in later API versions, so they are not offered here. 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/LineageCmd.java | 39 ++++ .../commands/lineage/AbstractLineageCmd.java | 36 ++++ .../commands/lineage/AbstractNavigateCmd.java | 53 +++++ .../cli/commands/lineage/DownstreamCmd.java | 39 ++++ .../cli/commands/lineage/LidOptions.java | 26 +++ .../cli/commands/lineage/ResolveCmd.java | 64 ++++++ .../tower/cli/commands/lineage/SearchCmd.java | 47 +++++ .../cli/commands/lineage/UpstreamCmd.java | 39 ++++ .../tower/cli/commands/lineage/ViewCmd.java | 53 +++++ .../responses/lineage/LineageRecordView.java | 66 ++++++ .../responses/lineage/LineageRecordsList.java | 67 +++++++ .../responses/lineage/LineageResolved.java | 36 ++++ .../tower/cli/lineage/LineageCmdTest.java | 188 ++++++++++++++++++ 15 files changed, 760 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/LineageCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/AbstractLineageCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/AbstractNavigateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/DownstreamCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/LidOptions.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/UpstreamCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/lineage/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordsList.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/lineage/LineageResolved.java create mode 100644 src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java 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/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/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/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/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)); + } +} From 4664673b1d8f7e2235e72fdfd1df43c308548c44 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:15:39 +0200 Subject: [PATCH 4/7] feat(studios): add logs Platform API 1.200.0 added the studio session log endpoint, but tw had no way to read it. tw studios logs prints the process log of the latest session of a studio, one page at a time: --next takes the cursor printed at the end of the previous page and --max-length caps the characters returned. SDK 1.200.0 types LogPage.entries as Object (the spec declares an Iterator) while Platform sends a JSON array, so the log entries are read as a List, as tw runs view log does. The test compares against the platform line separator, since println writes CRLF on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/StudiosCmd.java | 2 + .../tower/cli/commands/studios/LogsCmd.java | 53 ++++++++++++++++ .../cli/responses/studios/StudioLog.java | 62 +++++++++++++++++++ .../tower/cli/studios/StudiosCmdTest.java | 32 ++++++++++ 4 files changed, 149 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/studios/LogsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java 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/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/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/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); + } + } } From 8bcc192878a4af1904581435fe5061564b3500c9 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:16:31 +0200 Subject: [PATCH 5/7] feat(compute-envs): add per-user CPU and spot attempt limits to Intelligent Compute Platform API 1.200.0 added maxCpusPerUser and maxSpotAttempts to the Seqera scheduler configuration, but tw could not set them. tw compute-envs add aws-cloud, azure-cloud and google-cloud gain --max-cpus-per-user, which caps the vCPUs one user may hold across their runs, and --max-spot-attempts, which bounds the spot attempts per task before giving up on spot capacity (falling back to on-demand with SPOT_FIRST). Both join the Intelligent Compute option group of each platform. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../computeenvs/platforms/AwsCloudPlatform.java | 8 ++++++++ .../computeenvs/platforms/AzCloudPlatform.java | 10 +++++++++- .../computeenvs/platforms/GoogleCloudPlatform.java | 10 +++++++++- .../computeenvs/platforms/AwsCloudPlatformTest.java | 6 ++++++ .../cli/computeenvs/platforms/AzCloudPlatformTest.java | 10 ++++++++-- .../computeenvs/platforms/GoogleCloudPlatformTest.java | 10 ++++++++-- 6 files changed, 48 insertions(+), 6 deletions(-) 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/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 From 596fd3e6dd19999a4b414072677b5417f367b6b3 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:16:49 +0200 Subject: [PATCH 6/7] docs: document the commands added for API 1.200 The Agents section gains launch and runs list|view, a Lineage section follows it, and the studios entry in 'Other additions' gains logs. Examples use bash code blocks, as markdownlint MD014 rejects $-prompted blocks that show no output. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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`. From aab0fa90b851229da52afafce2c62c10510b917b Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Fri, 2 Oct 2026 19:17:42 +0200 Subject: [PATCH 7/7] build(native): register reflection metadata for the API 1.200 commands Native images only see the picocli option fields and the Jackson response fields that reflect-config.json lists. These entries, for agents launch and runs, lineage and studios logs, come from the tracing agent run of the full suite on the 1.230 branch. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 147 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) 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,