From aca89db8c70827ba7b1dc3b02321890da9eae771 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:47:44 +0200 Subject: [PATCH 1/5] 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 68c8c9e7..b90593d9 100644 --- a/src/main/java/io/seqera/tower/cli/Tower.java +++ b/src/main/java/io/seqera/tower/cli/Tower.java @@ -31,6 +31,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; @@ -72,6 +73,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 41de2bb1..83ca26af 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -31,6 +31,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; @@ -253,6 +254,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 bdcac700df4790cd74f36e26a42f2ab13ee48780 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:47:45 +0200 Subject: [PATCH 2/5] 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 | 59 +++++++++++++++++++ .../tower/cli/studios/StudiosCmdTest.java | 47 +++++++++++++++ 4 files changed, 161 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..729af04e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioLog.java @@ -0,0 +1,59 @@ +/* + * 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; + +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))); + + if (log == null || log.getEntries() == null || log.getEntries().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; + } + + log.getEntries().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 ec0458d4..0046aa39 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; @@ -3141,4 +3143,49 @@ void testViewShowsMountPaths(OutputType format, MockServerClient mock) throws Js assertTrue(out.stdOut.contains("s3://aaa-my-bucket/data/inputs, s3://aaa-my-bucket"), 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); + } + } + + @Test + void testLogsPending(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("GET").withPath("/studios/3e8370e7/log").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"log\": {\"entries\": [], \"pending\": true}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "logs", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("Waiting for the output log"), out.stdOut); + } } From d468e1e28a25c45e7d5eceb43dc7a9a17d0c5831 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:16 +0200 Subject: [PATCH 3/5] feat(lineage): resolve runs by session ID and run name, and show the pipeline API 1.211.0 made GET /lineage/resolve take runName with sessionId: a resumed run shares the session ID of its parent, so the session ID alone does not identify one run. 'lineage resolve --session-id' now requires --run-name and sends both; the SDK bump passed null until now. API 1.230.0 added the pipeline to lineage records (DisplayData pipelineName/pipelineId) and the pipeline/pipelineId search qualifiers. 'lineage view' shows a Pipeline row and 'lineage search --query' documents the new qualifiers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/lineage/ResolveCmd.java | 7 ++++++- .../tower/cli/commands/lineage/SearchCmd.java | 2 +- .../cli/responses/lineage/LineageRecordView.java | 1 + .../seqera/tower/cli/lineage/LineageCmdTest.java | 16 ++++++++++++---- 4 files changed, 20 insertions(+), 6 deletions(-) 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 index ec7ae0eb..6300add2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/ResolveCmd.java @@ -47,18 +47,23 @@ public static class Source { public String filePath; } + // A resumed run shares the session ID of its parent, so the run name is needed to pick one run public static class Run { @Option(names = {"--session-id"}, description = "Nextflow session ID of the run", required = true) public String sessionId; + + @Option(names = {"--run-name"}, description = "Nextflow run name", required = true) + public String runName; } @Override protected Response exec() throws ApiException { Long wspId = workspaceId(workspace.workspace); String sessionId = source.run != null ? source.run.sessionId : null; + String runName = source.run != null ? source.run.runName : null; - String lid = lineageApi().resolveLineage(wspId, sessionId, source.filePath).getLid(); + String lid = lineageApi().resolveLineage(wspId, sessionId, runName, 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 index f045bfc7..27d8b586 100644 --- a/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/lineage/SearchCmd.java @@ -30,7 +30,7 @@ ) 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'.") + @Option(names = {"-q", "--query"}, description = "Search query. Combines free text and the qualifiers: `type`, `workflow`, `task`, `pipeline`, `pipelineId`, `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)") 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 index f84e766a..80a5fe4d 100644 --- a/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java +++ b/src/main/java/io/seqera/tower/cli/responses/lineage/LineageRecordView.java @@ -51,6 +51,7 @@ public void toString(PrintWriter out) { table.addRow("Run ID", Objects.toString(display.getWorkflowId(), "")); table.addRow("Run name", Objects.toString(display.getRunName(), "")); table.addRow("Process", Objects.toString(display.getProcessName(), "")); + table.addRow("Pipeline", Objects.toString(display.getPipelineName(), "")); table.print(); if (record.getData() != null) { diff --git a/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java index 79e018dc..ecb2d648 100644 --- a/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/lineage/LineageCmdTest.java @@ -53,7 +53,7 @@ class LineageCmdTest extends BaseCmdTest { "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"} + "displayData": {"workspaceId": 75887156211589, "workflowId": "4abc", "runName": "happy_turing", "processName": "MULTIQC", "pipelineName": "nf-core/rnaseq"} } """; @@ -88,10 +88,11 @@ 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)) + .withQueryStringParameter("sessionId", "f1e2d3") + .withQueryStringParameter("runName", "happy_turing"), 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"); + ExecOut out = exec(format, mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--run-name", "happy_turing"); assertOutput(format, out, new LineageResolved(WSP_REF, "lid://wf789")); } @@ -110,9 +111,16 @@ void testResolveFilePath(OutputType format, MockServerClient mock) { assertOutput(format, out, new LineageResolved(WSP_REF, "lid://abc123/report.html")); } + @Test + void testResolveRequiresRunNameWithSessionId(MockServerClient mock) { + ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3"); + + assertEquals(2, out.exitCode); + } + @Test void testResolveRejectsRunAndFilePath(MockServerClient mock) { - ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--file-path", "/data/x"); + ExecOut out = exec(mock, "lineage", "resolve", "-w", WSP_ID, "--session-id", "f1e2d3", "--run-name", "happy_turing", "--file-path", "/data/x"); assertEquals(2, out.exitCode); } From fe299b5bdc7243fa02186dac148aa896addf8022 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 17:35:51 +0200 Subject: [PATCH 4/5] docs: document the lineage commands and studio logs A Lineage section with examples, and 'logs' added to the studios entry in 'Other additions'. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/USAGE.md b/USAGE.md index c7ac488a..5062c0e8 100644 --- a/USAGE.md +++ b/USAGE.md @@ -754,6 +754,16 @@ tw managed-identities add -o my-org -n hpc1 -p slurm -H login.hpc.example.com tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.ssh/id_rsa ``` +## 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 resolve -w my-org/my-workspace --session-id --run-name +tw lineage upstream -w my-org/my-workspace -i +``` + ## Other additions - `tw compute-envs enable|disable`: enable or disable a compute environment. @@ -765,7 +775,7 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s - `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. List the Nextflow and Fusion versions you can launch with `tw info versions` (`-c fusion` for Fusion). - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. -- `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 40ab2ae3bb9a4b93cd0c6be778c08bf5c4392fa2 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 17:35:53 +0200 Subject: [PATCH 5/5] build(native): register reflection metadata for the lineage and studio logs commands Entries for the lineage and 'studios logs' commands and responses, taken from a full test run under the GraalVM tracing agent. Response classes register allDeclaredFields, since Jackson serializes every field for -o json. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 2cc75b0b..3854e104 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -214,6 +214,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, @@ -1503,6 +1509,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, @@ -2171,6 +2242,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, @@ -2776,6 +2854,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, @@ -3210,6 +3306,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,